diff --git a/e2e/calendar.spec.js b/e2e/calendar.spec.js index 507422f..467e482 100644 --- a/e2e/calendar.spec.js +++ b/e2e/calendar.spec.js @@ -2573,4 +2573,91 @@ test.describe('FreeShard WebCalendar E2E Suite', () => { } } }); + + test.describe('Issue #110 — stylesheet and markup agree', () => { + // Computed value of a design token, as an rgb() string comparable to getComputedStyle colours. + async function tokenColor(page, token) { + return page.evaluate(t => { + const probe = document.createElement('span'); + probe.style.color = `var(${t})`; + document.body.appendChild(probe); + const c = getComputedStyle(probe).color; + probe.remove(); + return c; + }, token); + } + + test('a warning toast carries its own glyph and the warning border colour', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('#app')).not.toHaveClass(/hidden/, { timeout: 10000 }); + await page.locator('#calendar-list .calendar-item').first().locator('[data-share-cal]').click(); + await expect(page.locator('#share-cal-dialog-backdrop')).toBeVisible(); + + // Adding a share with nothing entered is the share flow's warning. + await page.click('#share-add-btn'); + const toast = page.locator('#toast-container .toast.warning'); + await expect(toast).toBeVisible(); + await expect(toast.locator('.toast-icon')).not.toHaveText('ℹ'); + await expect(toast.locator('.toast-icon')).toHaveText('⚠'); + const border = await toast.evaluate(el => { + const s = getComputedStyle(el); + return { width: s.borderLeftWidth, color: s.borderLeftColor }; + }); + expect(border).toEqual({ width: '3px', color: await tokenColor(page, '--warning') }); + + // The user-search suggestions sit over the dialog's content on an opaque surface. + await page.locator('#share-search-input').fill('testuser'); + const dropdown = page.locator('#share-autocomplete-dropdown'); + await expect(dropdown).not.toHaveClass(/hidden/, { timeout: 10000 }); + expect(await dropdown.evaluate(el => getComputedStyle(el).backgroundColor)).toBe(await tokenColor(page, '--surface')); + }); + + test('an attendee\'s participation status renders as a muted pill', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('#app')).not.toHaveClass(/hidden/, { timeout: 10000 }); + await page.click('#new-event-btn'); + await page.fill('#ev-attendee-name', 'Test User'); + await page.fill('#ev-attendee-email', 'testuser@example.com'); + await page.click('#add-attendee-btn'); + + const status = page.locator('#ev-attendee-list .attendee-status').first(); + await expect(status).toHaveText('Ausstehend'); + const style = await status.evaluate(el => { + const s = getComputedStyle(el); + return { color: s.color, paddingLeft: s.paddingLeft, borderRadius: s.borderTopLeftRadius }; + }); + expect(style.color).toBe(await tokenColor(page, '--ink-muted')); + expect(parseFloat(style.paddingLeft)).toBeGreaterThan(0); + expect(parseFloat(style.borderRadius)).toBeGreaterThan(0); + }); + + test('empty lists use the muted empty-state text', async ({ page }) => { + await page.route('**/api/msgraph/accounts', route => route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ success: true, available: true, accounts: [] }), + })); + await page.goto('/'); + await expect(page.locator('#app')).not.toHaveClass(/hidden/, { timeout: 10000 }); + await page.click('#sidebar-settings-btn'); + await page.click('#settings-add-account-btn'); + await page.click('#btn-link-msgraph'); + await expect(page.locator('#msgraph-modal-backdrop')).not.toHaveClass(/hidden/); + + const empty = page.locator('#msgraph-accounts-list .empty-state'); + await expect(empty).toHaveText('Keine Office 365 Konten verbunden.'); + expect(await empty.evaluate(el => getComputedStyle(el).color)).toBe(await tokenColor(page, '--ink-muted')); + }); + + test('the welcome dialog\'s calendar glyph is drawn in the accent line colour', async ({ page }) => { + // Registered after the suite's init script, so it runs later and re-enables the welcome dialog. + await page.addInitScript(() => { + if (window.origin !== 'null') window.localStorage.removeItem('welcome-popup-closed'); + }); + await page.goto('/'); + await expect(page.locator('#welcome-modal-backdrop')).not.toHaveClass(/hidden/, { timeout: 10000 }); + const glyph = page.locator('#welcome-modal .modal-body svg').first(); + expect(await glyph.evaluate(el => getComputedStyle(el).stroke)).toBe(await tokenColor(page, '--line')); + await page.click('#welcome-btn-close'); + }); + }); }); diff --git a/public/css/app.css b/public/css/app.css index ef7673d..e83ce14 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -119,11 +119,6 @@ a { color: inherit; text-decoration: none; } text-transform: uppercase; letter-spacing: var(--ls-label); color: var(--ink-muted); } -.fs-mono { - font: 400 var(--fs-mono)/1 var(--font-mono); - letter-spacing: var(--ls-mono); - color: var(--ink-muted); -} /* ── 5. Loading Screen ────────────────────────────────────────────────────── */ .loading-screen { @@ -158,7 +153,7 @@ a { color: inherit; text-decoration: none; } background: var(--surface); border-right: var(--border-w) solid var(--border); display: flex; flex-direction: column; - overflow-y: auto; overflow-x: hidden; + overflow: hidden; /* .sidebar-content scrolls; the footer stays put */ height: 100%; position: relative; /* containing block for .sidebar-settings-drawer */ } @@ -240,7 +235,7 @@ a { color: inherit; text-decoration: none; } } .mini-cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px; } -.mini-cal-dow, .mini-cal-day-name { +.mini-cal-dow { font: 600 9px/1 var(--font-ui); text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-muted); text-align: center; @@ -516,6 +511,10 @@ a { color: inherit; text-decoration: none; } .event-chip-title { overflow: hidden; text-overflow: ellipsis; } +.recur-icon { + margin-left: 4px; flex-shrink: 0; + display: inline-block; vertical-align: middle; +} .month-more { font: 600 10px/1 var(--font-ui); color: var(--ink-muted); margin-top: 2px; cursor: pointer; @@ -938,6 +937,18 @@ textarea.form-input { height: auto; padding: var(--sp-3); resize: vertical; } } .attendee-name { font-size: var(--fs-small); color: var(--ink); } .attendee-email { font-size: 11px; color: var(--ink-muted); font-family: var(--font-mono); } +/* PARTSTAT pill; the second class is the RFC 5545 value */ +.attendee-status { + margin-left: auto; + padding: 1px var(--sp-2); + border: var(--border-w) solid currentColor; + border-radius: var(--radius-pill); + font-size: 11px; white-space: nowrap; + color: var(--ink-muted); +} +.attendee-status.ACCEPTED { color: var(--success); } +.attendee-status.TENTATIVE { color: var(--warning); } +.attendee-status.DECLINED { color: var(--danger); } .attendee-remove { color: var(--ink-muted); transition: color var(--t); padding: 2px; border-radius: 4px; } .attendee-remove:hover { color: var(--danger); } .attendee-remove svg { width: 13px; height: 13px; display: block; } @@ -1092,15 +1103,10 @@ textarea.form-input { height: auto; padding: var(--sp-3); resize: vertical; } .toast.success { border-left: 3px solid var(--data); } .toast.error { border-left: 3px solid var(--danger); } .toast.info { border-left: 3px solid var(--line); } +.toast.warning { border-left: 3px solid var(--warning); } /* ── 19. Keyboard focus chrome ────────────────────────────────────────────── */ /* Teal outline + slight scale on keyboard nav only (DECISIONS.md) */ -.fs-focusable { transform-origin: center; } -.fs-focusable:focus-visible { - outline: 2px solid var(--line); - outline-offset: 3px; - position: relative; z-index: 3; -} /* Buttons & tiles get mild scale; full-width rows do not */ .toolbar-btn:focus-visible, .btn:focus-visible { @@ -1121,7 +1127,6 @@ textarea.form-input { height: auto; padding: var(--sp-3); resize: vertical; } } .sidebar.open { transform: translateX(0); visibility: visible; transition: transform var(--t-slow); } .app { flex-direction: column; } - .month-grid-header-day { font-size: 9px; } /* Responsive Grid-based Toolbar Layout */ .toolbar { @@ -1312,26 +1317,6 @@ textarea.form-input, .form-textarea { } .onboarding-form { display: flex; flex-direction: column; } -/* Sidebar section/account classes the old HTML used — now remapped */ -.sidebar-title { font: 600 var(--fs-h3)/1 var(--font-ui); color: var(--line); } - -/* Calendar/Account list sections — old class names still referenced */ -.calendar-list-section, -.account-list-section { - padding: var(--sp-4) var(--sp-3); - border-top: var(--border-w) solid var(--border); -} -.section-header { - display: flex; align-items: center; justify-content: space-between; - margin-bottom: var(--sp-2); -} -.section-title { - font: 600 var(--fs-label)/1 var(--font-ui); - text-transform: uppercase; letter-spacing: var(--ls-label); - color: var(--ink-muted); -} - -/* Mini calendar section compatibility */ .mini-calendar { padding: 0 var(--sp-1); } @@ -1434,7 +1419,14 @@ textarea.form-input, .form-textarea { background: rgba(255, 255, 255, 0.04); border-color: #555; } -.oauth-icon { +.btn-msgraph { + margin-top: 8px; + background: #0078D4; /* Microsoft brand blue */ + border-color: #0078D4; + color: #ffffff; +} +.oauth-icon, +.google-badge-icon { flex-shrink: 0; } .modal-divider { @@ -1459,14 +1451,6 @@ textarea.form-input, .form-textarea { } /* Sidebar Restructuring & Drawer */ -/* NOTE: must not set `position` here — this rule comes after the mobile media - query and would override the off-canvas `position: fixed` of the sidebar. */ -.sidebar { - display: flex; - flex-direction: column; - height: 100%; - overflow: hidden !important; -} .sidebar-content { flex: 1; overflow-y: auto; @@ -1687,6 +1671,7 @@ textarea.form-input, .form-textarea { [data-theme="dark"] .kanban-card { background: var(--surface-bg-alt); } +.kanban-card-body { min-width: 0; } .kanban-card-title { font-weight: 600; font-size: 14px; @@ -1756,6 +1741,12 @@ textarea.form-input, .form-textarea { } /* ── Empty State Styles ─────────────────────────────────────────────────── */ +.empty-state { + padding: var(--sp-4) var(--sp-3); + text-align: center; + font-size: var(--fs-small); + color: var(--ink-muted); +} .kanban-empty-state { margin: 12px 0 16px 0; padding: 16px 20px; @@ -1817,6 +1808,9 @@ textarea.form-input, .form-textarea { margin-top: 3px; cursor: pointer; } +.sidebar-task-info { flex: 1; min-width: 0; cursor: pointer; } +.sidebar-task-meta { font-size: 0.75rem; color: var(--ink-muted); } +.sidebar-tasks-empty { font-size: 0.78rem; color: var(--ink-muted); padding: 4px 8px; } .sidebar-task-title { font-size: 0.82rem; font-weight: 500; @@ -1960,11 +1954,6 @@ textarea.form-input, .form-textarea { background: #c69500; } -/* ── Print & Vector PDF Export Utility Classes ────────────────────────────────── */ -.print-only { - display: none !important; -} - /* ── Print & Vector PDF Export Styles ─────────────────────────────────────── */ @media print { body { @@ -1983,19 +1972,11 @@ textarea.form-input, .form-textarea { .status-bar, .modal-backdrop, .popover, - .notifications-toast, #kanban-side-drawer, - .drawer-backdrop, .shortcuts-modal-backdrop, - .toolbar-tz-select-wrapper, - .quick-add-container, - .header-actions, - .no-print { + .toolbar-tz-select-wrapper { display: none !important; } - .print-only { - display: block !important; - } .app { display: block !important; overflow: visible !important; @@ -2018,7 +1999,8 @@ textarea.form-input, .form-textarea { } .calendar-container, .month-grid, - .week-grid, + .week-grid-body, + .day-grid-body, .agenda-view, .kanban-board { width: 100% !important; @@ -2028,11 +2010,11 @@ textarea.form-input, .form-textarea { break-inside: avoid !important; page-break-inside: avoid !important; } - .month-day-cell, + .month-day, .week-day-col, .event-chip, .kanban-card, - .agenda-item { + .agenda-event { border: 1px solid #000 !important; color: #000 !important; background: #ffffff !important; diff --git a/public/index.html b/public/index.html index e5ec01f..2c3bcd2 100644 --- a/public/index.html +++ b/public/index.html @@ -688,11 +688,11 @@
-
+
-
+
@@ -762,7 +762,7 @@ Mit Google Kalender verbinden - `; const googleBadge = c.source === 'google' - ? ` + ? ` @@ -1756,7 +1756,7 @@ function renderSettingsCalendars() { `; const googleBadge = c.source === 'google' - ? ` + ? ` @@ -2539,7 +2539,7 @@ export function showToast(message, type = 'info') { setStatus('Fehler: ' + message); } - const icons = { success: '✓', error: '✕', info: 'ℹ' }; + const icons = { success: '✓', error: '✕', info: 'ℹ', warning: '⚠' }; toast.innerHTML = ` ${icons[type] || icons.info} ${escHtml(message)} diff --git a/public/js/calendar.js b/public/js/calendar.js index 5355898..2f7005c 100644 --- a/public/js/calendar.js +++ b/public/js/calendar.js @@ -308,9 +308,9 @@ export function chipHtml(ev) { const timeStr = ev.allDay ? '' : `${fmtTime(ev.start)}`; const labelText = ev.allDay ? ev.title : `${fmtTime(ev.start)} ${ev.title}`; const recurIcon = (ev.recurrence || ev.masterUid) - ? `` + ? `` : ''; - return `
` + ? `` : ''; block.innerHTML = ` @@ -813,7 +813,7 @@ export function agendaEventHtml(ev) { : `${fmtTime(ev.start)}${ev.end ? ' – ' + fmtTime(ev.end) : ''}`; const recurIcon = (ev.recurrence || ev.masterUid) - ? `` + ? `` : ''; const labelText = `${timeStr} ${ev.title}`; diff --git a/public/js/invitations.js b/public/js/invitations.js index b4c4bef..ee1d2cb 100644 --- a/public/js/invitations.js +++ b/public/js/invitations.js @@ -106,11 +106,11 @@ export function escHtml(str) { export function renderInvitationsHTML(invList, options = {}) { if (options.error || invitationsError) { - return '

Fehler beim Laden der Einladungen.

'; + return '

Fehler beim Laden der Einladungen.

'; } if (!Array.isArray(invList) || invList.length === 0) { - return '

Keine ausstehenden Einladungen vorhanden.

'; + return '

Keine ausstehenden Einladungen vorhanden.

'; } return invList.map(inv => { diff --git a/public/js/tasks.js b/public/js/tasks.js index 89fcc51..41cbb83 100644 --- a/public/js/tasks.js +++ b/public/js/tasks.js @@ -512,7 +512,7 @@ export function renderSidebarTasks(container, countBadge, tasks, callbacks = {}, if (!container) return; if (pendingTasks.length === 0) { - container.innerHTML = ``; + container.innerHTML = ``; return; } @@ -529,9 +529,9 @@ export function renderSidebarTasks(container, countBadge, tasks, callbacks = {}, item.innerHTML = ` -