From edcf2ae8b061c110f929581e199d08fe7aff301f Mon Sep 17 00:00:00 2001 From: xarmian Date: Sat, 4 Apr 2026 13:02:12 -0400 Subject: [PATCH] Board view improvements: independent scrolling, unified cards, lane reorder, new-item modal (#61) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: independent board scrolling, unified card style, lane reordering, and new-item modal - BoardView: switch from CSS grid to flex layout with independent per-column scrolling, matching the Roles board UX - ItemCard: redesign to match Roles board card style β€” top row with optional collection badge + ref, compact meta row with colored status/priority text - Roles board: replace inline card markup with shared ItemCard component, add HTML5 drag-and-drop lane reordering (persisted via new API endpoint), rename "Highlight Mine" to "Mine", add "+ New" button with collection picker modal - Backend: add PUT /roles/board/lane-order endpoint and UpdateAgentRoleOrder store method for batch role sort_order updates - Collection page: board view now fills viewport height so columns have bounded scroll areas * fix: resolve svelte-check type error and remove unused CSS selectors - Add null guard on lane.role in openEditModal onclick - Remove unused .role-edit-actions, .role-btn-create, .role-btn-cancel CSS * fix: correct lane reorder insert index when dragging forward After splicing out the source lane, downstream indices shift left by one. Adjust the insert index when srcIdx < dstIdx to place the lane at the correct drop target position. --- internal/server/handlers_role_board.go | 21 + internal/server/server.go | 1 + internal/store/agent_roles.go | 30 + web/src/lib/api/client.ts | 5 + .../components/collections/BoardView.svelte | 14 +- .../components/collections/ItemCard.svelte | 188 ++++--- .../[workspace]/[collection]/+page.svelte | 7 + web/src/routes/[workspace]/roles/+page.svelte | 513 ++++++++++++------ 8 files changed, 515 insertions(+), 264 deletions(-) diff --git a/internal/server/handlers_role_board.go b/internal/server/handlers_role_board.go index 565ce2a9..c82aff70 100644 --- a/internal/server/handlers_role_board.go +++ b/internal/server/handlers_role_board.go @@ -27,6 +27,27 @@ func (s *Server) handleRoleBoardReorder(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } +// handleRoleBoardLaneReorder updates sort_order for roles (lane ordering). +func (s *Server) handleRoleBoardLaneReorder(w http.ResponseWriter, r *http.Request) { + workspaceID, ok := s.getWorkspaceID(w, r) + if !ok { + return + } + + var updates []store.RoleOrderUpdate + if err := decodeJSON(r, &updates); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + + if err := s.store.UpdateAgentRoleOrder(workspaceID, updates); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + // handleRoleBoard returns items across all collections grouped by agent role. // This powers the standalone role board page in the web UI. func (s *Server) handleRoleBoard(w http.ResponseWriter, r *http.Request) { diff --git a/internal/server/server.go b/internal/server/server.go index 93bec542..581236ce 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -250,6 +250,7 @@ func (s *Server) setupRouter() { // Role Board (cross-collection role-based view) r.Get("/roles/board", s.handleRoleBoard) r.Put("/roles/board/reorder", s.handleRoleBoardReorder) + r.Put("/roles/board/lane-order", s.handleRoleBoardLaneReorder) // Agent Roles r.Route("/agent-roles", func(r chi.Router) { diff --git a/internal/store/agent_roles.go b/internal/store/agent_roles.go index 5368f985..952f3be6 100644 --- a/internal/store/agent_roles.go +++ b/internal/store/agent_roles.go @@ -385,6 +385,36 @@ func (s *Store) GetRoleBoardItems(workspaceID string, params RoleBoardParams) ([ return result, nil } +// RoleOrderUpdate represents a single role's sort_order update for lane reordering. +type RoleOrderUpdate struct { + RoleID string `json:"role_id"` + SortOrder int `json:"sort_order"` +} + +// UpdateAgentRoleOrder batch-updates sort_order for a list of roles. +func (s *Store) UpdateAgentRoleOrder(workspaceID string, updates []RoleOrderUpdate) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + stmt, err := tx.Prepare("UPDATE agent_roles SET sort_order = ?, updated_at = ? WHERE id = ? AND workspace_id = ?") + if err != nil { + return fmt.Errorf("prepare role order update: %w", err) + } + defer stmt.Close() + + ts := now() + for _, u := range updates { + if _, err := stmt.Exec(u.SortOrder, ts, u.RoleID, workspaceID); err != nil { + return fmt.Errorf("update sort order for role %s: %w", u.RoleID, err) + } + } + + return tx.Commit() +} + // RoleSortUpdate represents a single item's role_sort_order update. type RoleSortUpdate struct { ItemID string `json:"item_id"` diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 497fac1e..a525ea05 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -188,6 +188,11 @@ export const api = { request(`/workspaces/${ws}/roles/board/reorder`, { method: 'PUT', body: JSON.stringify(updates) + }), + reorderLanes: (ws: string, updates: { role_id: string; sort_order: number }[]) => + request(`/workspaces/${ws}/roles/board/lane-order`, { + method: 'PUT', + body: JSON.stringify(updates) }) }, diff --git a/web/src/lib/components/collections/BoardView.svelte b/web/src/lib/components/collections/BoardView.svelte index b1be1fef..0064363c 100644 --- a/web/src/lib/components/collections/BoardView.svelte +++ b/web/src/lib/components/collections/BoardView.svelte @@ -177,7 +177,7 @@ {#if items.length === 0} {:else} -
+
{#each columnOrder as colValue (colValue)} {@const colItems = columnData[colValue] ?? []}
.board-view { - display: grid; - grid-template-columns: repeat(var(--col-count, 3), 1fr); + display: flex; gap: var(--space-5); + flex: 1; + min-height: 0; + overflow-x: auto; } .kanban-column { display: flex; flex-direction: column; - min-width: 0; + flex: 1 0 0; + min-width: 220px; transition: transform 0.15s ease; } @@ -289,6 +292,7 @@ font-weight: 600; font-size: 0.9em; cursor: grab; + flex-shrink: 0; } .column-header:active { @@ -413,6 +417,8 @@ border-radius: var(--radius); padding: var(--space-2); transition: background 0.15s ease; + overflow-y: auto; + min-height: 0; } .column-cards:global(.drop-target) { diff --git a/web/src/lib/components/collections/ItemCard.svelte b/web/src/lib/components/collections/ItemCard.svelte index 33fca63b..4607115a 100644 --- a/web/src/lib/components/collections/ItemCard.svelte +++ b/web/src/lib/components/collections/ItemCard.svelte @@ -8,6 +8,7 @@ collection: Collection; compact?: boolean; focused?: boolean; + showCollection?: boolean; statusOptions?: string[]; onStatusClick?: (item: Item, newStatus: string) => void; progress?: { total: number; done: number } | null; @@ -15,7 +16,7 @@ relationLabels?: Record; } - let { item, collection, compact = false, focused = false, statusOptions, onStatusClick, progress = null, progressLabel = 'tasks', relationLabels = {} }: Props = $props(); + let { item, collection, compact = false, focused = false, showCollection = false, statusOptions, onStatusClick, progress = null, progressLabel = 'tasks', relationLabels = {} }: Props = $props(); let wsSlug = $derived(page.params.workspace ?? ''); let fields = $derived(parseFields(item)); @@ -46,20 +47,6 @@ onStatusClick(item, nextStatus); } - function relativeTime(dateStr: string): string { - const now = Date.now(); - const then = new Date(dateStr).getTime(); - const diff = now - then; - const minutes = Math.floor(diff / 60000); - if (minutes < 1) return 'just now'; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - if (days < 30) return `${days}d ago`; - return new Date(dateStr).toLocaleDateString(); - } - function statusColor(status: string): string { switch (status) { case 'open': return 'var(--text-secondary)'; @@ -86,44 +73,56 @@ -
+
+ {#if showCollection && item.collection_name} + + {#if item.collection_icon}{item.collection_icon} {/if}{item.collection_name} + + {/if} {#if itemRef}{itemRef}{/if} +
+ +
{item.title}
-
+
{#if statusField && fields.status} {#if statusCyclable} {:else} - - {formatLabel(fields.status)} + + {formatLabel(fields.status).toUpperCase()} {/if} {/if} {#if priorityField && fields.priority} - + {#if statusField && fields.status}·{/if} + {formatLabel(fields.priority)} {/if} {#if fields.phase && relationLabels[fields.phase]} - - {relationLabels[fields.phase]} - + · + {relationLabels[fields.phase]} {/if} {#if item.agent_role_name} - + · + {#if item.agent_role_icon}{item.agent_role_icon} {/if}{item.agent_role_name} {/if} + {#if item.assigned_user_name} + {item.assigned_user_name} + {/if}
{#if progress && progress.total > 0} @@ -134,23 +133,16 @@ {progress.done}/{progress.total} {progressLabel}
{/if} - -
diff --git a/web/src/routes/[workspace]/[collection]/+page.svelte b/web/src/routes/[workspace]/[collection]/+page.svelte index 3fdbba68..d4ac1c1c 100644 --- a/web/src/routes/[workspace]/[collection]/+page.svelte +++ b/web/src/routes/[workspace]/[collection]/+page.svelte @@ -896,6 +896,13 @@ .collection-page.board-active { max-width: none; padding: var(--space-6) var(--space-6); + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; + } + .board-active .page-header { + flex-shrink: 0; } .loading { diff --git a/web/src/routes/[workspace]/roles/+page.svelte b/web/src/routes/[workspace]/roles/+page.svelte index ac5591e1..5b2156f6 100644 --- a/web/src/routes/[workspace]/roles/+page.svelte +++ b/web/src/routes/[workspace]/roles/+page.svelte @@ -3,9 +3,11 @@ import { onMount } from 'svelte'; import { api } from '$lib/api/client'; import { workspaceStore } from '$lib/stores/workspace.svelte'; + import { collectionStore } from '$lib/stores/collections.svelte'; import { uiStore } from '$lib/stores/ui.svelte'; - import { parseFields, formatItemRef, itemUrlId } from '$lib/types'; - import type { Item, RoleBoardLane, AgentRole } from '$lib/types'; + import { itemUrlId } from '$lib/types'; + import type { Item, Collection, RoleBoardLane, AgentRole } from '$lib/types'; + import ItemCard from '$lib/components/collections/ItemCard.svelte'; import { dndzone, TRIGGERS, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action'; import type { DndEvent } from 'svelte-dnd-action'; @@ -19,6 +21,60 @@ // Highlight: dim cards not assigned to current user let highlightMine = $state(false); + // New item modal state + let newItemDialogEl = $state(null); + let newItemCollectionSlug = $state(''); + let newItemTitle = $state(''); + let newItemSaving = $state(false); + + let eligibleCollections = $derived( + collectionStore.collections.filter(c => !['conventions', 'playbooks'].includes(c.slug)) + ); + + function openNewItem() { + newItemCollectionSlug = ''; + newItemTitle = ''; + newItemDialogEl?.showModal(); + } + + function closeNewItem() { + newItemDialogEl?.close(); + newItemCollectionSlug = ''; + newItemTitle = ''; + } + + function selectCollection(slug: string) { + newItemCollectionSlug = slug; + // Focus the title input after selection + requestAnimationFrame(() => { + const input = newItemDialogEl?.querySelector('.new-item-title-input'); + input?.focus(); + }); + } + + async function submitNewItem() { + if (!newItemTitle.trim() || !newItemCollectionSlug || newItemSaving) return; + newItemSaving = true; + try { + await api.items.create(wsSlug, newItemCollectionSlug, { + title: newItemTitle.trim() + }); + closeNewItem(); + await loadData(); + } catch (err) { + console.error('Failed to create item:', err); + } finally { + newItemSaving = false; + } + } + + function handleNewItemKeydown(e: KeyboardEvent) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + submitNewItem(); + } + } + // Role editing modal state let dialogEl = $state(null); let dialogMode = $state<'edit' | 'create'>('create'); @@ -67,6 +123,71 @@ const touchDragDelayMs = 500; let isDragging = $state(false); + // Lane (header) drag-and-drop state + let draggedLaneKey = $state(null); + let dragOverLaneKey = $state(null); + + function handleLaneDragStart(e: DragEvent, key: string) { + if (key === '__unassigned') { e.preventDefault(); return; } + draggedLaneKey = key; + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', key); + } + } + + function handleLaneDragOver(e: DragEvent, key: string) { + if (!draggedLaneKey || key === draggedLaneKey || key === '__unassigned') return; + e.preventDefault(); + dragOverLaneKey = key; + } + + function handleLaneDragLeave() { + dragOverLaneKey = null; + } + + async function handleLaneDrop(e: DragEvent, key: string) { + e.preventDefault(); + if (!draggedLaneKey || key === '__unassigned') { draggedLaneKey = null; dragOverLaneKey = null; return; } + + // Reorder the assigned lanes (skip unassigned) + const assignedLanes = lanes.filter((l) => l.role); + const srcIdx = assignedLanes.findIndex((l) => l.role!.id === draggedLaneKey); + const dstIdx = assignedLanes.findIndex((l) => l.role!.id === key); + + if (srcIdx >= 0 && dstIdx >= 0 && srcIdx !== dstIdx) { + const [moved] = assignedLanes.splice(srcIdx, 1); + // After removing from srcIdx, indices shift left β€” adjust if moving forward + const insertIdx = srcIdx < dstIdx ? dstIdx - 1 : dstIdx; + assignedLanes.splice(insertIdx, 0, moved); + + // Rebuild lanes with new order + const unassigned = lanes.filter((l) => !l.role); + lanes = [...unassigned, ...assignedLanes]; + + // Persist new sort order + const updates = assignedLanes.map((lane, i) => ({ + role_id: lane.role!.id, + sort_order: i + })); + + try { + await api.agentRoles.reorderLanes(wsSlug, updates); + } catch (err) { + console.error('Failed to persist lane order:', err); + await loadData(); + } + } + + draggedLaneKey = null; + dragOverLaneKey = null; + } + + function handleLaneDragEnd() { + draggedLaneKey = null; + dragOverLaneKey = null; + } + // Mutable lane data for DnD β€” keyed by role ID (or '__unassigned') let laneData = $state>({}); @@ -242,22 +363,8 @@ } } - function statusColor(status: string): string { - const s = status.toLowerCase(); - if (s === 'done' || s === 'completed' || s === 'closed') return 'var(--accent-green)'; - if (s === 'in progress' || s === 'in_progress' || s === 'active') return 'var(--accent-blue)'; - if (s === 'blocked') return 'var(--accent-orange)'; - if (s === 'todo' || s === 'open' || s === 'backlog') return 'var(--text-muted)'; - return 'var(--text-secondary)'; - } - - function priorityColor(priority: string): string { - const p = priority.toLowerCase(); - if (p === 'critical' || p === 'urgent') return 'var(--accent-orange)'; - if (p === 'high') return 'var(--accent-amber)'; - if (p === 'medium') return 'var(--accent-blue)'; - if (p === 'low') return 'var(--accent-teal)'; - return 'var(--text-muted)'; + function collectionForItem(item: Item): Collection | undefined { + return collectionStore.collections.find(c => c.slug === item.collection_slug); } @@ -279,12 +386,57 @@ class:active={highlightMine} onclick={() => highlightMine = !highlightMine} > - Highlight Mine + Mine +
+ + + { if (e.target === newItemDialogEl) closeNewItem(); }}> +
+ {#if !newItemCollectionSlug} +
+

New Item

+ +
+
+ {#each eligibleCollections as coll} + + {/each} +
+ {:else} + {@const selectedColl = eligibleCollections.find(c => c.slug === newItemCollectionSlug)} +
+ +

New {selectedColl?.icon} {selectedColl?.name?.replace(/s$/, '') ?? 'Item'}

+ +
+
+ + +
+ {/if} +
+
+ { if (e.target === dialogEl) closeModal(); }}>
@@ -369,10 +521,26 @@
{#each orderedLanes as lane (lane.role?.id ?? '__unassigned')} {@const isUnassigned = !lane.role} -
-
+ {@const laneId = lane.role?.id ?? '__unassigned'} +
+ +
handleLaneDragStart(e, laneId)} + ondragover={(e) => handleLaneDragOver(e, laneId)} + ondragleave={handleLaneDragLeave} + ondrop={(e) => handleLaneDrop(e, laneId)} + ondragend={handleLaneDragEnd} + >
{#if lane.role} + β Ώ {lane.role.icon || '🤖'} {lane.role.name} {:else} @@ -380,7 +548,7 @@ {/if} {lane.items.length} {#if lane.role} - + {/if}
{#if lane.role?.tools} @@ -403,45 +571,15 @@ oncontextmenu={(e) => e.preventDefault()} > {#each (laneData[laneKey(lane)] ?? []) as item (item.id)} - {@const fields = parseFields(item)} - {@const ref = formatItemRef(item)} - {@const status = fields.status ?? ''} - {@const priority = fields.priority ?? ''} + {@const coll = collectionForItem(item)} {/each} {#if (laneData[laneKey(lane)] ?? []).length === 0 && !isDragging} @@ -527,6 +665,134 @@ border-color: var(--accent-blue); } + .new-item-btn { + background: var(--accent-blue); + color: white; + border: none; + border-radius: var(--radius); + padding: var(--space-2) var(--space-4); + font-size: 0.85em; + font-weight: 600; + cursor: pointer; + transition: filter 0.15s; + } + .new-item-btn:hover { + filter: brightness(1.15); + } + + /* ── New Item Modal ──────────────────────────────────────────────── */ + .new-item-dialog { + border: none; + border-radius: var(--radius-lg); + padding: 0; + background: var(--bg-secondary); + color: var(--text-primary); + max-width: 400px; + width: 90vw; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.3); + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + margin: 0; + } + .new-item-dialog::backdrop { + background: rgba(0, 0, 0, 0.5); + } + .new-item-content { + padding: var(--space-5); + } + .new-item-content .dialog-header { + display: flex; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-5); + } + .new-item-content .dialog-header h2 { + flex: 1; + font-size: 1.05em; + font-weight: 700; + margin: 0; + } + .back-btn { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 1.1em; + padding: var(--space-1) var(--space-2); + border-radius: var(--radius); + } + .back-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + .collection-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--space-3); + } + .collection-pick { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-2); + padding: var(--space-4) var(--space-3); + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + } + .collection-pick:hover { + border-color: var(--accent-blue); + background: var(--bg-hover); + } + .collection-pick-icon { + font-size: 1.5em; + } + .collection-pick-name { + font-size: 0.85em; + font-weight: 600; + color: var(--text-primary); + } + .new-item-form { + display: flex; + flex-direction: column; + gap: var(--space-4); + } + .new-item-title-input { + background: var(--bg-primary); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--space-3) var(--space-4); + font-size: 0.95em; + width: 100%; + } + .new-item-title-input:focus { + outline: none; + border-color: var(--accent-blue); + } + .new-item-create-btn { + background: var(--accent-blue); + color: white; + border: none; + border-radius: var(--radius); + padding: var(--space-3) var(--space-5); + font-size: 0.9em; + font-weight: 600; + cursor: pointer; + transition: filter 0.15s; + } + .new-item-create-btn:hover:not(:disabled) { + filter: brightness(1.15); + } + .new-item-create-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + /* ── Lanes Container ──────────────────────────────────────────────── */ .lanes-container { display: flex; @@ -549,6 +815,26 @@ max-height: 100%; } + .lane.dragging-source { + opacity: 0.4; + } + .lane.drag-over-left { + box-shadow: -3px 0 0 0 var(--accent-blue); + } + .lane-drag-handle { + cursor: grab; + color: var(--text-muted); + font-size: 0.85em; + user-select: none; + opacity: 0; + transition: opacity 0.15s; + } + .lane-header:hover .lane-drag-handle { + opacity: 0.6; + } + .lane-header[draggable="true"] { + cursor: grab; + } .lane-header { padding: var(--space-4) var(--space-4) var(--space-3); border-bottom: 1px solid var(--border); @@ -636,8 +922,8 @@ font-size: 0.85em; } - /* ── Item Card ────────────────────────────────────────────────────── */ - .item-card { + /* ── Fallback Card ───────────────────────────────────────────────── */ + .fallback-card { display: block; padding: var(--space-3); background: var(--bg-primary); @@ -645,87 +931,6 @@ border-radius: var(--radius); text-decoration: none; color: inherit; - transition: border-color 0.15s, background 0.15s; - } - .item-card:hover { - border-color: var(--text-muted); - background: var(--bg-hover); - } - - .card-top-row { - display: flex; - align-items: center; - gap: var(--space-2); - margin-bottom: var(--space-1); - flex-wrap: wrap; - } - - .collection-badge { - display: inline-flex; - align-items: center; - gap: 3px; - font-size: 0.7em; - background: var(--bg-tertiary); - padding: 1px 7px; - border-radius: 10px; - color: var(--text-muted); - white-space: nowrap; - } - .coll-icon { - font-size: 1em; - } - .coll-name { - font-weight: 600; - } - - .item-ref { - font-family: var(--font-mono); - font-size: 0.7em; - color: var(--text-muted); - white-space: nowrap; - } - - .card-title { - font-size: 0.875em; - font-weight: 600; - color: var(--text-primary); - line-height: 1.35; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - } - - .card-meta { - display: flex; - align-items: center; - gap: var(--space-2); - flex-wrap: wrap; - margin-top: var(--space-2); - } - - .status-badge { - font-size: 0.7em; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.04em; - } - - .priority-badge { - font-size: 0.7em; - font-weight: 600; - text-transform: capitalize; - } - - .assigned-user { - font-size: 0.7em; - color: var(--text-muted); - margin-left: auto; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 100px; } /* ── Empty State ──────────────────────────────────────────────────── */ @@ -1000,10 +1205,6 @@ flex-shrink: 0; text-align: center; } - .role-edit-actions { - display: flex; - gap: var(--space-2); - } .role-btn { padding: 5px 12px; font-size: 0.82em; @@ -1026,24 +1227,6 @@ .role-btn-save:hover { filter: brightness(1.1); } - .role-btn-create { - width: 100%; - padding: 8px; - background: var(--accent-blue); - color: white; - border-color: var(--accent-blue); - font-weight: 500; - } - .role-btn-create:hover:not(:disabled) { - filter: brightness(1.1); - } - .role-btn-create:disabled { - opacity: 0.5; - cursor: not-allowed; - } - .role-btn-cancel { - color: var(--text-muted); - } .role-btn-danger { color: var(--accent-orange); }