akif/arclet-boards

Arclet Boards — private Kanban workspaces with AppID, DataK3, shared web and MCP actions.

A remix template · 1 remixes · Remix akif/arclet-boards

app/web/static/app.js

'use strict';

const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, char => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[char]));
const E = escapeHtml;
const COLORS = ['indigo','blue','teal','amber','rose','violet','slate'];
const state = {session:null, workspaces:[], workspaceId:null, boards:[], tasks:[], overdue:[], board:null, card:null, view:'dashboard', filter:'', boardView:'kanban', request:0};
let modalReturnFocus = null;
const paths = {
  grid:'<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
  boards:'<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16M15 4v16"/>',
  check:'<rect x="3" y="3" width="18" height="18" rx="4"/><path d="m7 12 3 3 7-7"/>',
  clock:'<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
  shield:'<path d="m12 3 7 3v6c0 5-7 9-7 9s-7-4-7-9V6z"/><path d="m9 12 2 2 4-4"/>',
  search:'<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4 4"/>',
  plus:'<path d="M12 5v14M5 12h14"/>',
  arrow:'<path d="M5 12h14m-5-5 5 5-5 5"/>',
  lock:'<rect x="5" y="10" width="14" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/>',
  people:'<circle cx="9" cy="8" r="3"/><path d="M3 21v-3a6 6 0 0 1 12 0v3M17 5a3 3 0 0 1 0 6m2 10v-3a6 6 0 0 0-2-4"/>',
  list:'<path d="M9 6h12M9 12h12M9 18h12M3 6h1M3 12h1M3 18h1"/>',
  spark:'<path d="m12 3 2.5 6.5L21 12l-6.5 2.5L12 21l-2.5-6.5L3 12l6.5-2.5z"/>',
  edit:'<path d="m16 3 5 5-12 12-6 1 1-6zM13 6l5 5"/>',
};
const icon = name => `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true">${paths[name] || paths.boards}</svg>`;
const initials = address => (address || '?').split('@')[0].split(/[. _-]/).filter(Boolean).slice(0,2).map(part => part[0]).join('').toUpperCase();
const colorClass = value => `color-${COLORS.includes(value) ? value : 'indigo'}`;
const canEdit = () => state.board && state.board.role !== 'viewer';
const currentWorkspace = () => state.workspaces.find(workspace => workspace.id === state.workspaceId);
const currentBoards = () => state.boards.filter(board => board.workspace_id === state.workspaceId);
const dateLabel = value => value ? new Intl.DateTimeFormat(undefined,{month:'short',day:'numeric',timeZone:'UTC'}).format(new Date(`${value}T12:00:00Z`)) : '';
const timeLabel = value => new Intl.DateTimeFormat(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}).format(new Date(value));

async function api(url, options = {}, attempt = 0) {
  let response;
  try { response = await fetch(url, {credentials:'same-origin', ...options}); }
  catch (error) { if (attempt < 1) return api(url,options,attempt+1); throw new Error('Connection interrupted. Your form is still here; please try again.'); }
  const data = await response.json().catch(() => ({}));
  if (response.status === 401) {
    const error = new Error('Your session has ended. Sign in again to continue.');
    error.status = 401; throw error;
  }
  if (!response.ok) {
    if (response.status === 503 && attempt < 1) return api(url,options,attempt+1);
    const error = new Error(typeof data.detail === 'string' ? data.detail : 'The request could not be completed.');
    error.status = response.status; throw error;
  }
  $('#connection-state').textContent = 'Connected to Dodil';
  return data;
}
const action = (name, arguments_, requestId = crypto.randomUUID()) => api(`/api/actions/${name}`, {
  method:'POST', headers:{'Content-Type':'application/json','Idempotency-Key':requestId},
  body:JSON.stringify({arguments:arguments_})
});

function toast(message, error = false) {
  const region = $('#toast-region');
  region.replaceChildren();
  const node = document.createElement('div'); node.className = `toast${error ? ' error' : ''}`;
  node.textContent = message; region.append(node);
  setTimeout(() => node.remove(), 6500);
}

function failure(error) {
  $('#connection-state').textContent = error.status === 401 ? 'Session ended' : 'Connection needs attention';
  toast(error.message,true);
  if (error.status === 401) {
    showModal('Sign in again', 'Your work is stored safely in your workspace.',
      '<p>Your AppID session has ended. Sign in to continue working.</p>',
      '<a class="button primary" href="/.dodil/auth/login?return_to=/">Sign in with AppID</a>');
  }
}

async function refreshData() {
  // One batch per page refresh, not a parallel connection for every card.
  const [workspaces,boards,tasks,overdue] = await Promise.all([
    api('/api/workspaces'),api('/api/boards'),api('/api/tasks/mine'),api('/api/tasks/overdue')]);
  Object.assign(state,{workspaces,boards,tasks,overdue});
  if (!state.workspaceId || !workspaces.some(workspace => workspace.id === state.workspaceId)) {
    state.workspaceId = workspaces[0]?.id || null;
  }
  renderSidebar();
}

function renderSidebar() {
  $('#workspace-select').innerHTML = state.workspaces.length ? state.workspaces.map(workspace =>
    `<option value="${E(workspace.id)}" ${workspace.id === state.workspaceId ? 'selected' : ''}>${E(workspace.title)}</option>`).join('') : '<option value="">Your first workspace</option>';
  $('.workspace-icon').textContent = (currentWorkspace()?.title || 'A')[0].toUpperCase();
  $('#crumb-workspace').textContent = currentWorkspace()?.title || 'Your workspace';
  $('#board-count').textContent = String(currentBoards().length);
  $('#overdue-count').textContent = String(state.overdue.length);
  $('#board-shortcuts').innerHTML = currentBoards().slice(0,15).map(board =>
    `<a href="#board/${E(board.id)}" class="shortcut ${colorClass(board.color)} ${state.board?.id === board.id ? 'active' : ''}"><span class="color-dot"></span>${E(board.title)}</a>`).join('');
  $$('[data-nav]').forEach(link => link.classList.toggle('active',link.dataset.nav === state.view || (state.view === 'board' && link.dataset.nav === 'boards')));
  $$('[data-icon]').forEach(span => span.innerHTML = icon(span.dataset.icon));
}

function heading(title, subtitle, buttons = '', eyebrow = '') {
  return `<div class="page-heading"><div>${eyebrow ? `<span class="eyebrow">${E(eyebrow)}</span>` : ''}<h1>${E(title)}</h1><p>${E(subtitle)}</p></div><div class="heading-actions">${buttons}</div></div>`;
}
const newBoardButton = () => `<button class="button primary" data-action="new-board">${icon('plus')}New board</button>`;
const searchField = (placeholder, value = '', extra = '') => `<div class="search-field ${extra}"><span>${icon('search')}</span><input id="view-search" type="search" aria-label="${E(placeholder)}" placeholder="${E(placeholder)}" value="${E(value)}"></div>`;

function boardTile(board) {
  const percentage = board.card_count ? Math.round(board.completed_count / board.card_count * 100) : 0;
  return `<article class="board-tile ${colorClass(board.color)}"><a class="tile-link" href="#board/${E(board.id)}"><div class="board-cover"><span class="cover-symbol">${icon('boards')}</span><span class="cover-arc" aria-hidden="true"></span></div><div class="tile-content"><h3>${E(board.title)}</h3><p class="tile-description">${E(board.description || 'A clear space for your next great project.')}</p><div class="tile-meta"><span>${icon('check')}${board.card_count} cards</span><span>${percentage}% complete</span></div><progress class="progress-line" aria-label="${E(board.title)} completion" max="100" value="${percentage}"></progress></div></a><div class="tile-footer"><span>${icon('lock')}</span><span class="role-pill">${E(board.role)}</span><span>${board.list_count} lists</span></div></article>`;
}

function boardGrid(boards, includeNew = true) {
  return `<div class="board-grid">${boards.map(boardTile).join('')}${includeNew ? '<button class="new-board-tile" data-action="new-board"><span class="plus-box">+</span><strong>A fresh start</strong><small>Create a new board</small></button>' : ''}</div>`;
}

function taskRow(card, detailed = false) {
  return `<button class="task-row" data-action="open-card" data-card-id="${E(card.id)}" data-board-id="${E(card.board_id)}"><span class="task-check" aria-hidden="true"></span><span class="task-info"><strong>${E(card.title)}</strong><small>${E(card.board_title)}${detailed ? ` · ${E(card.list_title)}` : ''}</small></span>${detailed ? `<span class="role-pill">${E(card.role)}</span>` : ''}${card.due_date ? `<span class="due-chip ${card.overdue ? 'overdue' : ''}">${icon('clock')}${card.overdue ? 'Overdue · ' : ''}${E(dateLabel(card.due_date))}</span>` : ''}</button>`;
}

function renderDashboard() {
  const boards = currentBoards();
  const total = boards.reduce((sum, board) => sum + board.card_count,0);
  const completed = boards.reduce((sum, board) => sum + board.completed_count,0);
  const name = state.session.email.split('@')[0].split(/[. _-]/)[0];
  const greeting = name[0].toUpperCase() + name.slice(1);
  $('#main').innerHTML = heading(`A little clarity, ${greeting}.`, 'Everything you’re moving forward, in one place.', newBoardButton(), 'LET’S MAKE ROOM FOR GOOD WORK') +
    `<section class="stats-grid" aria-label="Workspace statistics">${[
      ['boards',boards.length,'Active boards'],['list',total,'Cards in this workspace'],['check',completed,'Cards completed'],['clock',state.overdue.length,'Overdue across your boards']
    ].map(([symbol,count,label]) => `<article class="stat-card"><span class="stat-icon">${icon(symbol)}</span><div><strong>${count}</strong><p>${E(label)}</p></div></article>`).join('')}</section>`+
    '<section><div class="section-heading"><div><h2>Your boards</h2><p>A space for every project. A place for every idea.</p></div><a class="text-button" href="#boards">View all boards →</a></div>' + boardGrid(boards.slice(0,5)) + '</section>'+
    `<div class="lower-grid"><section class="panel"><div class="panel-header"><h2>On your plate</h2><a class="text-button" href="#tasks">View my tasks →</a></div>${state.tasks.length ? state.tasks.slice(0,4).map(card => taskRow(card)).join('') : '<p class="empty-small">A little breathing room. No cards are assigned to you yet.</p>'}</section><aside class="note-panel"><span class="eyebrow">LESS NOISE. MORE MOMENTUM.</span><h2>Small steps.<br>Meaningful progress.</h2><p>Move an idea into a list. Give it an owner. Make room for what comes next.</p><button class="text-button" data-action="mcp-info">Work with your assistant →</button></aside></div>`;
}

function renderBoards() {
  const boards = currentBoards().filter(board => `${board.title} ${board.description}`.toLowerCase().includes(state.filter.toLowerCase()));
  $('#main').innerHTML = heading('Room for every project.', 'Shared goals, clear next steps, and a place to bring it all together.',newBoardButton(),'YOUR BOARDS')+
    `<div class="section-heading"><h2>All boards <span class="inline-count">${boards.length}</span></h2>${searchField('Find a board…',state.filter)}</div>`+
    (boards.length || !state.filter ? boardGrid(boards) : '<div class="empty-state"><h2>No boards match your search</h2><p>Try a different board name.</p></div>');
}

function renderTasks(overdue = false) {
  const cards = (overdue ? state.overdue : state.tasks).filter(card => `${card.title} ${card.board_title}`.toLowerCase().includes(state.filter.toLowerCase()));
  $('#main').innerHTML = heading(overdue ? 'A little attention goes a long way.' : 'Your next move starts here.', overdue ? 'Past-due cards across the boards you can access. Completed lists stay out of this view.' : 'Everything assigned to you, ordered by due date.', '',overdue ? 'OVERDUE' : 'MY TASKS')+
    `<div class="section-heading"><h2>${overdue ? 'Needs attention' : 'Assigned to you'} <span class="inline-count">${cards.length}</span></h2>${searchField('Find a task…',state.filter)}</div>`+
    (cards.length ? `<div class="task-table">${cards.map(card => taskRow(card,true)).join('')}</div>` : `<div class="empty-state"><span>${icon(overdue ? 'clock' : 'check')}</span><h2>${overdue ? 'All caught up.' : 'A clear runway.'}</h2><p>${state.filter ? 'No tasks match this search.' : overdue ? 'There are no overdue cards on your accessible boards.' : 'Cards assigned to you will appear here. Open a board to choose your next task.'}</p><a class="button" href="#boards">Explore your boards ${icon('arrow')}</a></div>`);
}

function renderOnboarding() {
  $('#main').innerHTML = `<section class="onboarding"><div class="empty-state"><span>${icon('spark')}</span><span class="eyebrow">WELCOME TO ARCLET</span><h1>A little space.<br>A lot of possibility.</h1><p>Your workspace brings people, projects, and the next small step together. Give yours a name to begin.</p><form data-form="workspace"><div class="form-error" role="alert"></div><div class="field"><label for="workspace-name">Workspace name</label><input id="workspace-name" name="title" required maxlength="80" placeholder="e.g. Studio North" autocomplete="organization"></div><button class="button primary" type="submit">Create your workspace ${icon('arrow')}</button></form></div></section>`;
}

function cardMarkup(card) {
  return `<article class="kanban-card" data-card="${E(card.id)}" draggable="${canEdit() && !state.filter}"><button class="card-open" data-action="open-card" data-card-id="${E(card.id)}" aria-label="Open ${E(card.title)}">${card.labels.length ? `<div class="card-labels">${card.labels.map(label => `<span class="label-chip ${colorClass(label.color)}">${E(label.name)}</span>`).join('')}</div>` : ''}<h3>${E(card.title)}</h3>${card.description ? `<p class="card-excerpt">${E(card.description)}</p>` : ''}<div class="card-bottom">${card.due_date ? `<span class="due-chip ${card.overdue ? 'overdue' : ''}">${icon('clock')}${card.overdue ? 'Overdue · ' : ''}${E(dateLabel(card.due_date))}</span>` : `<span class="card-id">#${E(card.id.slice(0,5))}</span>`}${card.assignee ? `<span class="avatar" title="${E(card.assignee)}">${E(initials(card.assignee))}</span>` : ''}</div></button></article>`;
}

function renderBoard() {
  const board = state.board;
  const cards = board.cards.filter(card => `${card.title} ${card.description} ${card.assignee || ''} ${card.labels.map(label => label.name).join(' ')}`.toLowerCase().includes(state.filter.toLowerCase()));
  const isOwner = board.role === 'owner';
  const edit = canEdit();
  $('#main').innerHTML = `<div class="page-heading board-heading"><div><span class="eyebrow">A SHARED SPACE TO MOVE THINGS FORWARD</span><h1>${E(board.title)}</h1><div class="board-subtitle">${icon('lock')}Private board <span>·</span><span class="role-pill">${E(board.role)}</span></div></div><div class="heading-actions">${edit ? `<button class="button" data-action="edit-board">${icon('edit')}Edit board</button>` : '<span class="view-readonly">Read-only access</span>'}<button class="button ${isOwner ? 'primary' : ''}" data-action="members">${icon('people')}${isOwner ? 'Invite people' : 'Members'}</button></div></div>`+
    `<div class="board-toolbar"><div class="view-tabs" role="group" aria-label="Board view"><button class="view-tab ${state.boardView === 'kanban' ? 'active' : ''}" data-action="board-view" data-view="kanban">${icon('boards')}Board</button><button class="view-tab ${state.boardView === 'overview' ? 'active' : ''}" data-action="board-view" data-view="overview">${icon('grid')}Overview</button></div><div class="board-toolbar-right"><div class="avatar-stack">${board.members.slice(0,4).map(member => `<span class="avatar" title="${E(member.email)}">${E(initials(member.email))}</span>`).join('')}</div>${searchField('Filter cards…',state.filter,'board-filter')}</div></div>`+
    (state.boardView === 'overview' ? `<section class="board-description">${E(board.description || 'Add a description to give your team a little context.')}</section><div class="board-detail-grid">${[[board.cards.length,'Cards on this board'],[board.lists.length,'Lists to keep work flowing'],[board.members.length,'People moving things forward']].map(([count,label]) => `<div class="panel"><strong>${count}</strong><p>${E(label)}</p></div>`).join('')}</div>` :
      `<section class="kanban" aria-label="${E(board.title)} Kanban board">${board.lists.map((list,index) => `<section class="kanban-column" aria-label="${E(list.title)}"><div class="column-head ${list.is_done ? 'done' : ''}"><span class="color-dot"></span><h2>${E(list.title)}</h2><span class="column-count">${cards.filter(card => card.list_id === list.id).length}</span>${edit ? `<div class="column-actions"><button class="icon-button" data-action="shift-list" data-list-id="${E(list.id)}" data-direction="-1" aria-label="Move ${E(list.title)} left" ${index === 0 ? 'disabled' : ''}>‹</button><button class="icon-button" data-action="shift-list" data-list-id="${E(list.id)}" data-direction="1" aria-label="Move ${E(list.title)} right" ${index === board.lists.length-1 ? 'disabled' : ''}>›</button><button class="icon-button" data-action="edit-list" data-list-id="${E(list.id)}" aria-label="Edit ${E(list.title)}">⋯</button></div>` : ''}</div><div class="drop-zone" data-list-id="${E(list.id)}">${cards.filter(card => card.list_id === list.id).map(cardMarkup).join('') || `<p class="no-cards">${state.filter ? 'No matching cards' : 'A little room for what’s next'}</p>`}</div>${edit ? `<button class="column-add" data-action="new-card" data-list-id="${E(list.id)}"><span>+</span>Add a card</button>` : ''}</section>`).join('')}${edit ? '<button class="add-list" data-action="new-list">+ Add another list</button>' : ''}</section>`);
}

async function renderRoute() {
  const request = ++state.request;
  const hash = location.hash.slice(1) || 'dashboard';
  const [view,id] = hash.split('/');
  state.view = ['dashboard','boards','tasks','overdue','board'].includes(view) ? view : 'dashboard';
  state.filter = '';
  $('#sidebar').classList.remove('is-open');
  $('.mobile-menu').setAttribute('aria-expanded','false');
  $('#main').setAttribute('aria-busy','true');
  try {
    if (view === 'board' && id) {
      const board = await api(`/api/boards/${encodeURIComponent(id)}`);
      if (request !== state.request) return;
      state.board = board; state.workspaceId = board.workspace_id;
      $('#crumb-view').textContent = board.title;
      renderBoard();
    } else {
      state.board = null;
      $('#crumb-view').textContent = {dashboard:'Overview',boards:'My boards',tasks:'My tasks',overdue:'Overdue'}[state.view] || 'Overview';
      renderCurrent();
    }
    renderSidebar();
  } catch (error) {
    if (request !== state.request) return;
    $('#main').innerHTML = `<div class="empty-state error-state"><span>${icon('lock')}</span><h2>${error.status === 404 ? 'This space isn’t available.' : 'We couldn’t load this space.'}</h2><p>${E(error.message)}</p><a class="button" href="#boards">Back to your boards</a></div>`;
    failure(error);
  } finally { $('#main').setAttribute('aria-busy','false'); }
}

function renderCurrent() {
  if (!state.workspaces.length) return renderOnboarding();
  if (state.view === 'board' && state.board) return renderBoard();
  if (state.view === 'boards') return renderBoards();
  if (state.view === 'tasks' || state.view === 'overdue') return renderTasks(state.view === 'overdue');
  renderDashboard();
}

async function reloadBoard() {
  if (state.board) state.board = await api(`/api/boards/${encodeURIComponent(state.board.id)}`);
  await refreshData(); renderCurrent();
}

function showModal(title, subtitle, body, footer = '', extraClass = '') {
  const modal = $('#modal');
  if (!modal.open) modalReturnFocus = document.activeElement;
  modal.className = extraClass;
  $('#modal-content').innerHTML = `<div class="modal-header"><div><h2 id="modal-title">${E(title)}</h2>${subtitle ? `<p>${E(subtitle)}</p>` : ''}</div><button class="icon-button" data-action="close-modal" aria-label="Close dialog">×</button></div><div class="modal-body">${body}</div>${footer ? `<div class="modal-footer">${footer}</div>` : ''}`;
  if (!modal.open) modal.showModal();
}
function closeModal() { $('#modal').close(); state.card = null; if (modalReturnFocus?.isConnected) modalReturnFocus.focus(); }
const formError = '<div class="form-error" role="alert"></div>';
const colorSelect = (name = 'color', selected = 'indigo') => `<select name="${E(name)}" aria-label="Color">${COLORS.map(color => `<option value="${color}" ${color === selected ? 'selected' : ''}>${color[0].toUpperCase()+color.slice(1)}</option>`).join('')}</select>`;
const inputField = (label,name,value='',required=true,limit=160) => `<div class="field"><label for="field-${E(name)}">${E(label)}</label><input id="field-${E(name)}" name="${E(name)}" value="${E(value)}" ${required ? 'required' : ''} maxlength="${limit}"></div>`;
const textField = (value = '') => `<div class="field"><label for="field-description">Description</label><textarea id="field-description" name="description" maxlength="20000" placeholder="A little context makes the next step clearer…">${E(value)}</textarea></div>`;
const saveFooter = (formId,label='Save changes') => `<button class="button" data-action="close-modal">Cancel</button><button class="button primary" type="submit" form="${formId}">${E(label)}</button>`;

function newWorkspaceModal() {
  showModal('A fresh space for your team.', 'Create another workspace, with its own boards and members.',
    `<form id="workspace-form" data-form="workspace">${formError}${inputField('Workspace name','title','',true,80)}</form>`,saveFooter('workspace-form','Create workspace'));
}
function boardForm(editing = false) {
  const board = editing ? state.board : {title:'',description:'',color:'indigo'};
  const workspace = currentWorkspace();
  if (!editing && !['owner','member'].includes(workspace?.role)) {
    showModal('Your shared workspace', 'You can view the boards shared with you.',
      '<p>Ask the workspace owner for member access to create boards here, or create a workspace of your own.</p>',
      '<button class="button primary" data-action="new-workspace">Create a workspace</button>'); return;
  }
  showModal(editing ? 'Make this space your own.' : 'Good things start with a board.', editing ? 'Keep the purpose clear for everyone.' : `Create a private board in ${workspace.title}.`,
    `<form id="board-form" data-form="${editing ? 'edit-board' : 'board'}">${formError}${inputField('Board name','title',board.title,true,100)}${textField(board.description)}<div class="field"><label for="board-color">A little color</label>${colorSelect('color',board.color)}</div></form>`,saveFooter('board-form',editing ? 'Save changes' : 'Create board'));
}

function listForm(id = null) {
  const list = state.board.lists.find(row => row.id === id);
  showModal(list ? 'A little list refinement.' : 'Make room for the next step.', 'Lists keep work moving in the right direction.',
    `<form id="list-form" data-form="list" data-list-id="${E(id || '')}">${formError}${inputField('List name','title',list?.title || '',true,80)}<label class="checkbox-label"><input type="checkbox" name="is_done" ${list?.is_done ? 'checked' : ''}>Cards in this list are complete</label><p class="field-hint">Completed cards won’t appear in overdue results.</p></form>`,saveFooter('list-form',list ? 'Save changes' : 'Create list'));
}

function assigneeSelect(value = null, disabled = false) {
  return `<select id="field-assignee" name="assignee" ${disabled ? 'disabled' : ''}><option value="">Unassigned</option>${state.board.members.map(member => `<option value="${E(member.email)}" ${value === member.email ? 'selected' : ''}>${E(member.email)}</option>`).join('')}</select>`;
}

function requireSavedCardDetails() {
  const form = $('#card-edit-form');
  if (!form || !state.card || state.card.role === 'viewer') return;
  const values = Object.fromEntries(new FormData(form));
  const expected = {title:state.card.title,description:state.card.description,
    assignee:state.card.assignee || '',due_date:state.card.due_date || '',
    labels:state.card.labels.map(label => `${label.name}:${label.color}`).join(', ')};
  if (Object.entries(expected).some(([key,value]) => values[key] !== value)) {
    throw new Error('Save your card details first. Your edits are still here.');
  }
}
function newCardForm(listId) {
  showModal('One small step forward.', `Add a card to ${state.board.lists.find(list => list.id === listId).title}.`,
    `<form id="card-form" data-form="card" data-list-id="${E(listId)}">${formError}${inputField('Card title','title')}${textField()}<div class="field-row"><div class="field"><label for="field-assignee">Assignee</label>${assigneeSelect()}</div><div class="field"><label for="field-due">Due date</label><input id="field-due" name="due_date" type="date"></div></div></form>`,saveFooter('card-form','Add card'));
}

async function openCard(cardId, boardId = null) {
  if (boardId && boardId !== state.board?.id) state.board = await api(`/api/boards/${encodeURIComponent(boardId)}`);
  const card = await api(`/api/cards/${encodeURIComponent(cardId)}`);
  state.card = card;
  const editable = card.role !== 'viewer';
  const activityNames = {created:'created this card',updated:'updated the details',assigned:'changed the assignee',due_date_set:'changed the due date',moved:'moved this card',reordered:'adjusted the card order',labels_updated:'updated labels',member_removed:'cleared an assignment after removing a member',checklist_item_added:'added a checklist item',checklist_item_changed:'updated a checklist item',list_completion_changed:'changed this list’s completion status'};
  const activities = card.activity.slice().reverse().map(event => {
    const details = Object.entries(event.changes).filter(([key,value]) => value && typeof value === 'object' && 'before' in value && key !== 'card' && key !== 'item').map(([key,value]) => `${key.replaceAll('_',' ')}: ${typeof value.before === 'object' ? JSON.stringify(value.before) : value.before ?? 'empty'} → ${typeof value.after === 'object' ? JSON.stringify(value.after) : value.after ?? 'empty'}`).join('\n');
    return `<div class="activity-row"><span class="avatar">${E(initials(event.actor_email))}</span><div><p><strong>${E(event.actor_email)}</strong> ${E(activityNames[event.action] || event.action.replaceAll('_',' '))}</p>${details ? `<div class="activity-change">${E(details.slice(0,1500))}</div>` : ''}<time datetime="${E(event.created_at)}">${E(timeLabel(event.created_at))}</time></div></div>`;
  }).join('');
  const labels = card.labels.map(label => `${label.name}:${label.color}`).join(', ');
  showModal('The details make the difference.', `${state.board.title} / ${state.board.lists.find(list => list.id === card.list_id)?.title || ''} · #${card.id.slice(0,6)}`,
    `<form id="card-edit-form" data-form="edit-card">${formError}<fieldset ${editable ? '' : 'disabled'} class="card-fields">${inputField('Card title','title',card.title)}${textField(card.description)}<div class="field-row"><div class="field"><label for="field-assignee">Assignee</label>${assigneeSelect(card.assignee)}</div><div class="field"><label for="field-due">Due date</label><input id="field-due" name="due_date" type="date" value="${E(card.due_date || '')}"></div></div><div class="field"><label for="field-labels">Labels</label><input id="field-labels" name="labels" value="${E(labels)}" placeholder="Design:indigo, Priority:rose"><p class="field-hint">Separate labels with commas. Optional color after a colon: indigo, blue, teal, amber, rose, violet, slate. Maximum 12.</p></div></fieldset></form>${editable ? `<section class="detail-section"><h3>${icon('boards')}Move this card</h3><form data-form="move-card" class="compact-form">${formError}<select name="list_id" aria-label="Destination list">${state.board.lists.map(list => `<option value="${E(list.id)}" ${list.id === card.list_id ? 'selected' : ''}>${E(list.title)}</option>`).join('')}</select><button class="button" type="submit">Move to end</button></form><p class="field-hint">This is also the keyboard and touch alternative to dragging cards on the board.</p></section>` : ''}<section class="detail-section"><h3>${icon('check')}Checklist <span class="inline-count">${card.checklist.filter(item => item.completed).length}/${card.checklist.length}</span></h3>${card.checklist.map(item => `<label class="checklist-row ${item.completed ? 'completed' : ''}"><input type="checkbox" data-item-id="${E(item.id)}" ${item.completed ? 'checked' : ''} ${editable ? '' : 'disabled'}><span>${E(item.title)}</span></label>`).join('') || '<p class="field-hint">Break a larger task into smaller steps.</p>'}${editable ? '<form data-form="checklist" class="compact-form"><input name="title" required maxlength="240" aria-label="New checklist item" placeholder="Add a small next step…"><button class="button" type="submit">Add item</button></form>' : ''}</section><section class="detail-section"><h3>${icon('clock')}Activity</h3>${activities || '<p class="field-hint">No activity yet.</p>'}</section>`,editable ? saveFooter('card-edit-form') : '<button class="button" data-action="close-modal">Close</button>','card-modal');
}

async function membersModal() {
  const members = await action('list_members',{board_id:state.board.id});
  const owner = state.board.role === 'owner';
  showModal('Good work is a team thing.', `People with access to ${state.board.title}.`,
    `<div class="permission-note">Owners manage members and all board content. Members can edit. Viewers can read. Workspace access alone never reveals private boards.</div>${members.map(member => `<div class="member-row"><span class="avatar">${E(initials(member.email))}</span><div class="member-info"><strong>${E(member.email)}</strong><small>${member.email === state.session.email ? 'You' : member.role === 'owner' ? 'Board owner' : 'Board member'}</small></div>${owner && member.role !== 'owner' ? `<select aria-label="Role for ${E(member.email)}" data-member-email="${E(member.email)}"><option value="member" ${member.role === 'member' ? 'selected' : ''}>Member</option><option value="viewer" ${member.role === 'viewer' ? 'selected' : ''}>Viewer</option></select><button class="icon-button" data-action="remove-member" data-email="${E(member.email)}" aria-label="Remove ${E(member.email)}">×</button>` : `<span class="role-pill">${E(member.role)}</span>`}</div>`).join('')}${owner ? '<section class="detail-section"><h3>Invite someone into the loop</h3><form data-form="invite"><div class="form-error" role="alert"></div><div class="field"><label for="invite-email">Email address</label><input id="invite-email" name="email" type="email" required maxlength="254" placeholder="teammate@company.com"></div><div class="field"><label for="invite-role">Board role</label><select id="invite-role" name="role"><option value="member">Member — can edit</option><option value="viewer">Viewer — read only</option></select></div><p class="field-hint">This grants board access. It does not send an email; share the board link with your teammate, and ask them to sign in or register through AppID with this email.</p><button class="button primary" type="submit">Grant board access</button></form></section>' : ''}`);
}

async function workspaceModal() {
  if (!state.workspaceId) return newWorkspaceModal();
  const workspace = await action('get_workspace',{workspace_id:state.workspaceId});
  showModal(workspace.title, 'Workspace access and private board access are managed separately.',
    `<div class="permission-note">${workspace.role === 'owner' ? 'You own this workspace. Inviting someone here lets them create boards as a member, or view this workspace as a viewer. Grant private board access from each board.' : `Your workspace role is ${E(workspace.role)}. Board permissions are managed by each board’s owner.`}</div>${(workspace.members || []).map(member => `<div class="member-row"><span class="avatar">${E(initials(member.email))}</span><div class="member-info"><strong>${E(member.email)}</strong></div><span class="role-pill">${E(member.role)}</span></div>`).join('')}${workspace.role === 'owner' ? '<section class="detail-section"><form data-form="workspace-invite"><div class="form-error" role="alert"></div><div class="field"><label for="workspace-email">Email address</label><input id="workspace-email" name="email" type="email" required></div><div class="field"><label for="workspace-role">Workspace role</label><select id="workspace-role" name="role"><option value="member">Member — can create boards</option><option value="viewer">Viewer</option></select></div><button class="button primary" type="submit">Grant workspace access</button></form></section>' : ''}`,
    '<button class="button" data-action="new-workspace">Create another workspace</button>');
}

function mcpModal() {
  const endpoint = `${location.origin}/mcp`;
  showModal('Your assistant, on the same page.', 'Real actions. The same permissions. Your own AppID sign-in.',
    `<p class="field-hint">Add this URL as a custom connector in Claude or ChatGPT, then sign in with your own AppID account:</p><code class="code-box">${E(endpoint)}</code><p class="field-hint">The workspace administrator may need to approve the registered OAuth client once. Your assistant can list your boards, add and move cards, assign work, review activity, and manage members if you are the owner.</p><hr><p class="field-hint">Try: “What’s on my plate?” · “Show overdue cards.” · “Add a card to my launch board.”</p>`,
    '<button class="button primary" data-action="copy-mcp">Copy endpoint</button>');
}

async function moveCard(cardId,listId,index) {
  const card = state.board.cards.find(row => row.id === cardId);
  if (!card) throw new Error('This card has changed. Refresh the board.');
  await action('move_card',{card_id:cardId,list_id:listId,index,version:card.version,board_version:state.board.version});
  await reloadBoard(); toast('A small move. A little more momentum.');
}

document.addEventListener('click',async event => {
  const button = event.target.closest('[data-action]'); if (!button || button.disabled) return;
  const type = button.dataset.action;
  try {
    if (type === 'toggle-nav') { const open = $('#sidebar').classList.toggle('is-open'); button.setAttribute('aria-expanded',String(open)); }
    else if (type === 'close-modal') closeModal();
    else if (type === 'new-workspace') newWorkspaceModal();
    else if (type === 'new-board') state.workspaces.length ? boardForm() : newWorkspaceModal();
    else if (type === 'edit-board') boardForm(true);
    else if (type === 'new-list') listForm();
    else if (type === 'edit-list') listForm(button.dataset.listId);
    else if (type === 'new-card') newCardForm(button.dataset.listId);
    else if (type === 'open-card') await openCard(button.dataset.cardId,button.dataset.boardId);
    else if (type === 'members') await membersModal();
    else if (type === 'workspace-manage') await workspaceModal();
    else if (type === 'mcp-info') mcpModal();
    else if (type === 'copy-mcp') { await navigator.clipboard.writeText(`${location.origin}/mcp`); toast('MCP endpoint copied.'); }
    else if (type === 'board-view') { state.boardView = button.dataset.view; renderBoard(); }
    else if (type === 'shift-list') {
      button.disabled = true;
      const ids = state.board.lists.map(list => list.id), from = ids.indexOf(button.dataset.listId), to = from + Number(button.dataset.direction);
      [ids[from],ids[to]] = [ids[to],ids[from]];
      await action('reorder_lists',{board_id:state.board.id,list_ids:ids,version:state.board.version});
      await reloadBoard(); toast('List order saved.');
    } else if (type === 'remove-member') {
      const address = button.dataset.email;
      showModal('Remove board access?', `${address} will no longer see this board.`,
        '<p class="field-hint">Their card assignments will be cleared and recorded in activity. You can grant access again later.</p>',
        `<button class="button" data-action="members">Keep access</button><button class="button danger" data-action="confirm-remove-member" data-email="${E(address)}">Remove access</button>`);
    } else if (type === 'confirm-remove-member') {
      button.disabled = true;
      await action('remove_member',{board_id:state.board.id,email:button.dataset.email});
      await reloadBoard(); await membersModal(); toast('Board access removed.');
    }
  } catch (error) { failure(error); }
});

document.addEventListener('submit',async event => {
  const form = event.target.closest('form[data-form]'); if (!form) return;
  event.preventDefault();
  if (form.dataset.busy === 'true') return;
  const data = Object.fromEntries(new FormData(form)), type = form.dataset.form;
  const errorBox = $('.form-error',form); if (errorBox) errorBox.textContent = '';
  form.dataset.busy = 'true';
  const submitters = [...$$('button[type="submit"]',form),...$$(`button[form="${form.id}"]`)];
  submitters.forEach(button => button.disabled = true);
  try {
    if (type === 'workspace') {
      const workspace = await action('create_workspace',{title:data.title}); state.workspaceId = workspace.id;
      closeModal(); await refreshData(); location.hash = 'dashboard'; renderCurrent(); toast('A fresh workspace. Ready for what’s next.');
    } else if (type === 'board') {
      const board = await action('create_board',{workspace_id:state.workspaceId,title:data.title,description:data.description,color:data.color});
      closeModal(); await refreshData(); location.hash = `board/${board.id}`;
    } else if (type === 'edit-board') {
      await action('update_board',{board_id:state.board.id,title:data.title,description:data.description,color:data.color,version:state.board.version});
      closeModal(); await reloadBoard(); toast('Board details saved.');
    } else if (type === 'list') {
      const list = state.board.lists.find(row => row.id === form.dataset.listId);
      await action(list ? 'update_list' : 'create_list',{...(list ? {list_id:list.id,version:list.version} : {board_id:state.board.id}),title:data.title,is_done:data.is_done === 'on'});
      closeModal(); await reloadBoard(); toast('List saved.');
    } else if (type === 'card') {
      await action('add_card',{board_id:state.board.id,list_id:form.dataset.listId,title:data.title,description:data.description,assignee:data.assignee || null,due_date:data.due_date || null});
      closeModal(); await reloadBoard(); toast('One small step, added.');
    } else if (type === 'edit-card') {
      const labels = data.labels.split(',').map(value => value.trim()).filter(Boolean).map(value => {
        const [name,color = 'indigo',...rest] = value.split(':');
        if (rest.length) throw new Error('Use Label:color, separated by commas.');
        return {name:name.trim(),color:color.trim().toLowerCase()};
      });
      await action('update_card',{card_id:state.card.id,title:data.title,description:data.description,assignee:data.assignee || null,due_date:data.due_date || null,labels,version:state.card.version});
      closeModal(); await reloadBoard(); toast('The details are saved.');
    } else if (type === 'move-card') {
      requireSavedCardDetails();
      const id = state.card.id;
      const index = state.board.cards.filter(card => card.list_id === data.list_id && card.id !== id).length;
      await moveCard(id,data.list_id,index); await openCard(id);
    } else if (type === 'invite') {
      await action('invite_member',{board_id:state.board.id,email:data.email,role:data.role});
      await reloadBoard(); await membersModal(); toast('Board access granted. Share the board link with your teammate.');
    } else if (type === 'workspace-invite') {
      await action('invite_workspace_member',{workspace_id:state.workspaceId,email:data.email,role:data.role});
      await refreshData(); await workspaceModal(); toast('Workspace access granted.');
    } else if (type === 'checklist') {
      requireSavedCardDetails();
      const id = state.card.id;
      await action('add_checklist_item',{card_id:id,title:data.title,version:state.card.version});
      await reloadBoard(); await openCard(id);
    }
  } catch (error) {
    if (errorBox?.isConnected) errorBox.textContent = error.message;
    else failure(error);
    if (error.status === 409) toast('Someone made a newer change. Close this dialog and refresh before saving again.',true);
    if (error.status === 401) failure(error);
  } finally {
    form.dataset.busy = 'false'; submitters.forEach(button => button.disabled = false);
  }
});

document.addEventListener('change',async event => {
  const target = event.target;
  try {
    if (target.id === 'workspace-select') { state.workspaceId = target.value; state.board = null; location.hash = 'dashboard'; renderSidebar(); renderCurrent(); }
    if (target.dataset.memberEmail) {
      target.disabled = true;
      await action('change_member_role',{board_id:state.board.id,email:target.dataset.memberEmail,role:target.value});
      await reloadBoard(); await membersModal(); toast('Member role updated.');
    }
    if (target.dataset.itemId) {
      requireSavedCardDetails();
      target.disabled = true;
      const cardId = state.card.id, item = state.card.checklist.find(row => row.id === target.dataset.itemId);
      await action('complete_checklist_item',{item_id:item.id,completed:target.checked,version:item.version});
      await reloadBoard(); await openCard(cardId);
    }
  } catch (error) {
    target.disabled = false;
    if (target.dataset.itemId) {
      const item = state.card?.checklist.find(row => row.id === target.dataset.itemId);
      if (item) target.checked = item.completed;
    }
    failure(error);
  }
});

document.addEventListener('input',event => {
  if (event.target.id !== 'view-search') return;
  const start = event.target.selectionStart;
  state.filter = event.target.value; renderCurrent();
  const input = $('#view-search'); input.focus();
  try { input.setSelectionRange(start,start); } catch { /* Search inputs may not support selection ranges. */ }
});

let draggedCard = null;
document.addEventListener('dragstart',event => {
  const card = event.target.closest('[data-card][draggable="true"]'); if (!card) return;
  draggedCard = card.dataset.card; event.dataTransfer.setData('text/plain',draggedCard);
  event.dataTransfer.effectAllowed = 'move'; card.classList.add('dragging');
});
document.addEventListener('dragover',event => {
  const zone = event.target.closest('.drop-zone');
  if (!zone || !draggedCard || !canEdit()) return;
  event.preventDefault(); event.dataTransfer.dropEffect = 'move';
  $$('.drop-zone').forEach(element => element.classList.toggle('drag-over',element === zone));
});
document.addEventListener('drop',async event => {
  const zone = event.target.closest('.drop-zone'); if (!zone || !draggedCard || !canEdit()) return;
  event.preventDefault();
  const id = draggedCard, listId = zone.dataset.listId;
  const siblings = $$('[data-card]',zone).filter(card => card.dataset.card !== id);
  let index = siblings.findIndex(card => event.clientY < card.getBoundingClientRect().top + card.getBoundingClientRect().height / 2);
  if (index < 0) index = siblings.length;
  draggedCard = null; $$('.drag-over').forEach(element => element.classList.remove('drag-over'));
  try { await moveCard(id,listId,index); } catch (error) { failure(error); if (error.status === 409) await reloadBoard().catch(failure); }
});
document.addEventListener('dragend',() => { draggedCard = null; $$('.dragging,.drag-over').forEach(element => element.classList.remove('dragging','drag-over')); });
$('#modal').addEventListener('click',event => { if (event.target === $('#modal')) { const rect = event.target.getBoundingClientRect(); if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) closeModal(); } });
$('#modal').addEventListener('cancel',() => { state.card = null; });
window.addEventListener('hashchange',renderRoute);
$('#sidebar').addEventListener('click',event => {
  if (event.target.closest('a[href^="#"]')) {
    $('#sidebar').classList.remove('is-open');
    $('.mobile-menu').setAttribute('aria-expanded','false');
  }
});
window.addEventListener('online',() => toast('Connection restored. Refreshing your workspace…'));
window.addEventListener('offline',() => { $('#connection-state').textContent = 'Offline'; toast('You’re offline. Changes need a connection to save.',true); });

async function start() {
  try {
    state.session = await api('/api/session');
    const letters = initials(state.session.email);
    $('#user-avatar').textContent = letters; $('#top-avatar').textContent = letters;
    $('#user-name').textContent = state.session.email;
    await refreshData(); await renderRoute();
  } catch (error) {
    $('#main').innerHTML = `<div class="empty-state error-state"><h1>A little pause.</h1><p>${E(error.message)}</p><button class="button primary" data-action="retry-start">Try again</button></div>`;
    failure(error);
  } finally { $('#main').setAttribute('aria-busy','false'); }
}
document.addEventListener('click',event => { if (event.target.closest('[data-action="retry-start"]')) start(); });
start();