Board view improvements: independent scrolling, unified cards, lane reorder, new-item modal (#61)

* 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.
This commit is contained in:
xarmian
2026-04-04 13:02:12 -04:00
committed by GitHub
parent 2f9e61ad9c
commit edcf2ae8b0
8 changed files with 515 additions and 264 deletions
+21
View File
@@ -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) {
+1
View File
@@ -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) {
+30
View File
@@ -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"`
+5
View File
@@ -188,6 +188,11 @@ export const api = {
request<void>(`/workspaces/${ws}/roles/board/reorder`, {
method: 'PUT',
body: JSON.stringify(updates)
}),
reorderLanes: (ws: string, updates: { role_id: string; sort_order: number }[]) =>
request<void>(`/workspaces/${ws}/roles/board/lane-order`, {
method: 'PUT',
body: JSON.stringify(updates)
})
},
@@ -177,7 +177,7 @@
{#if items.length === 0}
<EmptyState {collection} {wsSlug} {oncreate} />
{:else}
<div class="board-view" style:--col-count={columnOrder.length}>
<div class="board-view">
{#each columnOrder as colValue (colValue)}
{@const colItems = columnData[colValue] ?? []}
<div
@@ -259,15 +259,18 @@
<style>
.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) {
@@ -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<string, string>;
}
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 @@
</script>
<a href={itemUrl} class="item-card" class:compact class:focused>
<div class="card-title">
<div class="card-top-row">
{#if showCollection && item.collection_name}
<span class="collection-badge">
{#if item.collection_icon}{item.collection_icon} {/if}{item.collection_name}
</span>
{/if}
{#if itemRef}<span class="item-ref">{itemRef}</span>{/if}
</div>
<div class="card-title">
{item.title}
</div>
<div class="card-badges">
<div class="card-meta">
{#if statusField && fields.status}
{#if statusCyclable}
<button
class="badge status-badge status-btn"
class="meta-status meta-status-btn"
class:pulsing
style:--badge-color={statusColor(fields.status)}
style:color={statusColor(fields.status)}
onclick={cycleStatus}
title="Click to cycle status"
>
{formatLabel(fields.status)}
{formatLabel(fields.status).toUpperCase()}
</button>
{:else}
<span class="badge status-badge" style:--badge-color={statusColor(fields.status)}>
{formatLabel(fields.status)}
<span class="meta-status" style:color={statusColor(fields.status)}>
{formatLabel(fields.status).toUpperCase()}
</span>
{/if}
{/if}
{#if priorityField && fields.priority}
<span class="badge priority-badge" style:--badge-color={priorityColor(fields.priority)}>
{#if statusField && fields.status}<span class="meta-sep">&middot;</span>{/if}
<span class="meta-priority" style:color={priorityColor(fields.priority)}>
{formatLabel(fields.priority)}
</span>
{/if}
{#if fields.phase && relationLabels[fields.phase]}
<span class="badge phase-badge">
{relationLabels[fields.phase]}
</span>
<span class="meta-sep">&middot;</span>
<span class="meta-phase">{relationLabels[fields.phase]}</span>
{/if}
{#if item.agent_role_name}
<span class="badge role-badge">
<span class="meta-sep">&middot;</span>
<span class="meta-role">
{#if item.agent_role_icon}{item.agent_role_icon} {/if}{item.agent_role_name}
</span>
{/if}
{#if item.assigned_user_name}
<span class="meta-assignee">{item.assigned_user_name}</span>
{/if}
</div>
{#if progress && progress.total > 0}
@@ -134,23 +133,16 @@
<span class="card-progress-text">{progress.done}/{progress.total} {progressLabel}</span>
</div>
{/if}
<div class="card-footer">
{#if item.assigned_user_name}
<span class="assignee">{item.assigned_user_name}</span>
{/if}
<span class="updated" title={new Date(item.updated_at).toLocaleString()}>{relativeTime(item.updated_at)}</span>
</div>
</a>
<style>
.item-card {
display: flex;
flex-direction: column;
gap: var(--space-3);
background: var(--bg-secondary);
gap: var(--space-2);
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
border-radius: var(--radius);
padding: var(--space-4) var(--space-5);
text-decoration: none;
color: inherit;
@@ -172,92 +164,120 @@
padding: var(--space-3) var(--space-4);
}
.card-title {
font-size: 0.95em;
color: var(--text-primary);
line-height: 1.45;
font-weight: 500;
.card-top-row {
display: flex;
align-items: center;
gap: var(--space-2);
}
.collection-badge {
background: var(--bg-tertiary);
padding: 1px 7px;
border-radius: 10px;
font-size: 0.7em;
color: var(--text-muted);
white-space: nowrap;
}
.item-ref {
font-family: var(--font-mono);
font-size: 0.8em;
font-size: 0.75em;
color: var(--text-muted);
font-weight: 400;
margin-right: 4px;
white-space: nowrap;
}
.card-title {
font-size: 0.95em;
color: var(--text-primary);
line-height: 1.45;
font-weight: 600;
}
.compact .card-title {
font-size: 0.92em;
}
.card-badges {
.card-meta {
display: flex;
align-items: center;
gap: var(--space-2);
row-gap: var(--space-2);
gap: 5px;
flex-wrap: wrap;
}
.badge {
font-size: 0.8em;
padding: 3px 10px;
border-radius: var(--radius-sm);
.meta-status {
font-size: 0.7em;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
white-space: nowrap;
font-weight: 500;
color: var(--badge-color);
background: color-mix(in srgb, var(--badge-color) 15%, transparent);
}
.status-btn {
.meta-status-btn {
border: none;
background: none;
cursor: pointer;
line-height: inherit;
padding: 0;
font-family: inherit;
line-height: inherit;
transition: filter 0.1s, transform 0.1s;
}
.status-btn:hover {
.meta-status-btn:hover {
filter: brightness(1.3);
transform: scale(1.05);
}
.status-btn:active {
.meta-status-btn:active {
transform: scale(0.95);
}
.status-btn.pulsing {
.meta-status-btn.pulsing {
animation: status-pulse 0.3s ease-out;
}
@keyframes status-pulse {
0% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--badge-color) 50%, transparent);
text-shadow: 0 0 0 currentColor;
}
70% {
box-shadow: 0 0 0 6px color-mix(in srgb, var(--badge-color) 0%, transparent);
text-shadow: 0 0 8px currentColor;
}
100% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--badge-color) 0%, transparent);
text-shadow: 0 0 0 currentColor;
}
}
.role-badge {
font-size: 0.8em;
color: var(--accent-teal, var(--accent-blue));
background: color-mix(in srgb, var(--accent-teal, var(--accent-blue)) 12%, transparent);
padding: 3px 10px;
border-radius: var(--radius-sm);
.meta-sep {
font-size: 0.7em;
color: var(--text-muted);
}
.meta-priority {
font-size: 0.7em;
font-weight: 600;
white-space: nowrap;
}
.phase-badge {
font-size: 0.8em;
.meta-phase {
font-size: 0.7em;
font-weight: 500;
color: var(--accent-purple, var(--text-secondary));
background: color-mix(in srgb, var(--accent-purple, var(--text-secondary)) 12%, transparent);
padding: 3px 10px;
border-radius: var(--radius-sm);
white-space: nowrap;
}
.meta-role {
font-size: 0.7em;
font-weight: 500;
color: var(--accent-teal, var(--accent-blue));
white-space: nowrap;
}
.meta-assignee {
font-size: 0.7em;
font-weight: 500;
color: var(--accent-blue);
margin-left: auto;
white-space: nowrap;
}
@@ -285,26 +305,4 @@
white-space: nowrap;
flex-shrink: 0;
}
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.assignee {
font-size: 0.8em;
color: var(--accent-blue);
background: color-mix(in srgb, var(--accent-blue) 15%, transparent);
padding: 2px 8px;
border-radius: var(--radius-sm);
white-space: nowrap;
}
.updated {
font-size: 0.75em;
color: var(--text-muted);
margin-left: auto;
}
</style>
@@ -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 {
+348 -165
View File
@@ -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<HTMLDialogElement | null>(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<HTMLInputElement>('.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<HTMLDialogElement | null>(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<string | null>(null);
let dragOverLaneKey = $state<string | null>(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<Record<string, Item[]>>({});
@@ -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);
}
</script>
@@ -279,12 +386,57 @@
class:active={highlightMine}
onclick={() => highlightMine = !highlightMine}
>
Highlight Mine
Mine
</button>
<button class="new-item-btn" onclick={openNewItem}>+ New</button>
</div>
</header>
<!-- Role edit/create modal -->
<!-- New Item Modal -->
<dialog class="new-item-dialog" bind:this={newItemDialogEl} onclick={(e) => { if (e.target === newItemDialogEl) closeNewItem(); }}>
<div class="dialog-content new-item-content">
{#if !newItemCollectionSlug}
<div class="dialog-header">
<h2>New Item</h2>
<button class="dialog-close" onclick={closeNewItem}>✕</button>
</div>
<div class="collection-grid">
{#each eligibleCollections as coll}
<button class="collection-pick" onclick={() => selectCollection(coll.slug)}>
<span class="collection-pick-icon">{coll.icon || '📦'}</span>
<span class="collection-pick-name">{coll.name}</span>
</button>
{/each}
</div>
{:else}
{@const selectedColl = eligibleCollections.find(c => c.slug === newItemCollectionSlug)}
<div class="dialog-header">
<button class="back-btn" onclick={() => { newItemCollectionSlug = ''; newItemTitle = ''; }} title="Back"></button>
<h2>New {selectedColl?.icon} {selectedColl?.name?.replace(/s$/, '') ?? 'Item'}</h2>
<button class="dialog-close" onclick={closeNewItem}>✕</button>
</div>
<div class="new-item-form">
<input
class="new-item-title-input"
type="text"
placeholder="Title…"
bind:value={newItemTitle}
onkeydown={handleNewItemKeydown}
/>
<button
class="new-item-create-btn"
disabled={!newItemTitle.trim() || newItemSaving}
onclick={submitNewItem}
>
{newItemSaving ? 'Creating…' : 'Create'}
</button>
</div>
{/if}
</div>
</dialog>
<dialog class="roles-dialog" bind:this={dialogEl} onclick={(e) => { if (e.target === dialogEl) closeModal(); }}>
<div class="dialog-content">
<div class="dialog-header">
@@ -369,10 +521,26 @@
<div class="lanes-container">
{#each orderedLanes as lane (lane.role?.id ?? '__unassigned')}
{@const isUnassigned = !lane.role}
<div class="lane" class:unassigned={isUnassigned}>
<div class="lane-header">
{@const laneId = lane.role?.id ?? '__unassigned'}
<div
class="lane"
class:unassigned={isUnassigned}
class:dragging-source={draggedLaneKey === laneId}
class:drag-over-left={dragOverLaneKey === laneId}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="lane-header"
draggable={!isUnassigned}
ondragstart={(e) => handleLaneDragStart(e, laneId)}
ondragover={(e) => handleLaneDragOver(e, laneId)}
ondragleave={handleLaneDragLeave}
ondrop={(e) => handleLaneDrop(e, laneId)}
ondragend={handleLaneDragEnd}
>
<div class="lane-title-row">
{#if lane.role}
<span class="lane-drag-handle" title="Drag to reorder"></span>
<span class="lane-icon">{lane.role.icon || '&#129302;'}</span>
<span class="lane-name">{lane.role.name}</span>
{:else}
@@ -380,7 +548,7 @@
{/if}
<span class="lane-count">{lane.items.length}</span>
{#if lane.role}
<button class="lane-edit-btn" title="Edit role" onclick={() => openEditModal(lane.role)}>✎</button>
<button class="lane-edit-btn" title="Edit role" onclick={() => lane.role && openEditModal(lane.role)}>✎</button>
{/if}
</div>
{#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)}
<div class="card-wrapper" class:dimmed={highlightMine && currentUserId && item.assigned_user_id !== currentUserId}>
<a
href="/{wsSlug}/{item.collection_slug}/{itemUrlId(item)}"
class="item-card"
>
<div class="card-top-row">
{#if item.collection_icon || item.collection_name}
<span class="collection-badge">
{#if item.collection_icon}<span class="coll-icon">{item.collection_icon}</span>{/if}
{#if item.collection_name}<span class="coll-name">{item.collection_name}</span>{/if}
</span>
{/if}
{#if ref}
<span class="item-ref">{ref}</span>
{/if}
</div>
<div class="card-title">{item.title}</div>
<div class="card-meta">
{#if status}
<span class="status-badge" style="color: {statusColor(status)}">
{status}
</span>
{/if}
{#if priority}
<span class="priority-badge" style="color: {priorityColor(priority)}">
{priority}
</span>
{/if}
{#if item.assigned_user_name}
<span class="assigned-user">{item.assigned_user_name}</span>
{/if}
</div>
</a>
{#if coll}
<ItemCard {item} collection={coll} compact={true} showCollection={true} />
{:else}
<a href="/{wsSlug}/{item.collection_slug}/{itemUrlId(item)}" class="fallback-card">
<span class="card-title">{item.title}</span>
</a>
{/if}
</div>
{/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);
}