Calendar hover card: actions became icon+text; restore icon-only with warm tooltips #1098

Open
opened 2026-10-05 06:02:48 +00:00 by kayg · 4 comments
Owner

Owner report (2026-10-05, production 7c df92d4da1)

"Somehow all the actions in the hover preview card became label + text instead of just icons with warm tooltips?"

The Calendar hover preview card shows its actions as icon + text pills (Attach File, Voice Memo, Edit, Hide, Duplicate) in two wrapping rows. Restore the icon-only action buttons, grouped in one glass pill toolbar, each with the warm tooltip and its shortcut (CLAUDE.md Chrome rules: every toolbar action has the warm tooltip with its shortcut). Accessible names stay on the buttons. The same action list feeds the context menu, where labels remain (menus show text). Shared source: packages/ui/src/components/calendar/model.ts calendarItemActions (DESIGN §34, #581). Find which change turned labels on (git log -L on the preview card).

## Owner report (2026-10-05, production 7c df92d4da1) "Somehow all the actions in the hover preview card became label + text instead of just icons with warm tooltips?" The Calendar hover preview card shows its actions as icon + text pills (Attach File, Voice Memo, Edit, Hide, Duplicate) in two wrapping rows. Restore the icon-only action buttons, grouped in one glass pill toolbar, each with the warm tooltip and its shortcut (CLAUDE.md Chrome rules: every toolbar action has the warm tooltip with its shortcut). Accessible names stay on the buttons. The same action list feeds the context menu, where labels remain (menus show text). Shared source: packages/ui/src/components/calendar/model.ts calendarItemActions (DESIGN §34, #581). Find which change turned labels on (git log -L on the preview card).
Author
Owner

Starting on branch job/previewcard-1098 from dev base df92d4da12. I am tracing the action-label and shared pill edge regressions before changing code.

Starting on branch job/previewcard-1098 from dev base df92d4da129bfead879d417aef6e0514fc56ee96. I am tracing the action-label and shared pill edge regressions before changing code.
Author
Owner

Finding: commit cd435e354d
Author: kayg mail@kayg.org
Date: Thu Oct 1 14:12:04 2026 +0200

Polish Calendar blocks and Journal preview actions

commit a0eff3e99d
Author: kayg mail@kayg.org
Date: Sat Sep 26 21:03:43 2026 +0200

feat(web): support block actions in editor buttons and commit cd435e354d78ff44e225e5bfdab91db4179fb18a

Author: kayg mail@kayg.org
Date: Thu Oct 1 14:12:04 2026 +0200

Polish Calendar blocks and Journal preview actions

diff --git a/apps/web/e2e/calendar-crossday.mjs b/apps/web/e2e/calendar-crossday.mjs
index aba498958..c84b912f3 100644
--- a/apps/web/e2e/calendar-crossday.mjs
+++ b/apps/web/e2e/calendar-crossday.mjs
@@ -200,9 +200,12 @@ async function waitForView(page, view, records, sourceDate) {
if (view.name === 'week-source' && record.title === 'Midnight continuation') {
await first.click();
assert.equal(await page.locator(.block.actual.selected[data-key="${key}"]).count(), 2, 'selecting either Log segment selects the same Week item');

  •   			const preview = page.locator('article.preview[aria-label="Log entry"]');
    
  •   			const preview = page.locator('article.preview[aria-label="Journal"]');
      			await preview.waitFor();
    
  •   			assert.match(await preview.locator('.meta').textContent(), /23:58\s*–\s*00:30/, 'either segment previews the full source Log range');
    
  •   			const sourceTimes = await preview.locator('.date-time .time-chip').allTextContents();
    
  •   			assert.equal(sourceTimes.length, 2, 'either segment previews separate start and end time chips');
    
  •   			assert.match(sourceTimes[0], /23:58|11:58/);
    
  •   			assert.match(sourceTimes[1], /00:30|12:30/);
      		}
      	}
      }
    

diff --git a/apps/web/e2e/calendar-preview-421.mjs b/apps/web/e2e/calendar-preview-421.mjs
index 5018f659b..36003bc4b 100644
--- a/apps/web/e2e/calendar-preview-421.mjs
+++ b/apps/web/e2e/calendar-preview-421.mjs
@@ -84,7 +84,7 @@ try {
const response = await fetch('/api/v1/notes/journal/log', {
method: 'POST',
headers: { 'content-type': 'application/json' },

  •   	body: JSON.stringify({ text: '08:30 - 09:30 Preview Journal #area/proof', now: new Date(year, month - 1, date, 8, 30).toISOString(), timezone: zone })
    
  •   	body: JSON.stringify({ text: '08:30 - 09:30 Preview Journal #area/proof #area/selfcare', now: new Date(year, month - 1, date, 8, 30).toISOString(), timezone: zone })
      });
      const body = await response.json();
      if (!response.ok) throw new Error(`Journal fixture ${response.status}: ${JSON.stringify(body)}`);
    

@@ -102,7 +102,7 @@ try {
}, { day: today });

caldav = await startCalDAV(today, {
  •   timedEvents: [{ date: today, start: '15:00', end: '16:00', uid: 'e2e-preview-event', title: 'Preview Event #area/proof' }]
    
  •   timedEvents: [{ date: today, start: '15:00', end: '16:00', uid: 'e2e-preview-event', title: 'Preview Event #area/proof #area/selfcare' }]
    
    });
    await connectCalDAV(page, server.base, caldav);
    const route = /calendar/day/${today};
    @@ -127,6 +127,15 @@ try {
    await page.mouse.move(6, 6);
    const weekJournalForLift = page.locator([data-key^="log:${today}:${journal.id}"]);
    await weekJournalForLift.waitFor();
  • assert.equal((await weekJournalForLift.locator('.b-time-row .b-time-chip').allTextContents()).join(','), '08:30,09:30', 'Week Journal shows a clock row with separate start and end chips');
  • assert.equal((await weekJournalForLift.locator('.b-title').textContent())?.trim(), 'Preview Journal', 'the Journal title has its own grid line');
  • assert.equal((await weekJournalForLift.locator('.b-tags').textContent())?.replace(/\s/g, ''), 'proof+1', 'the grid shows a leaf Tag chip and collapses the rest to +N');
  • assert.equal(await weekJournalForLift.locator('.b-tags').getAttribute('title'), 'area/proof, area/selfcare', 'the grid chip keeps full Tags in its tooltip');
  • assert.deepEqual(await eventBlock.locator('.b-time-row .b-time-chip').allTextContents(), ['15:00', '16:00'], 'Week Event shows its time range as chips above the title');
  • assert.equal((await eventBlock.locator('.b-title').textContent())?.trim(), 'Preview Event', 'the Event title line does not repeat its Tags');
  • assert.equal((await eventBlock.locator('.b-tags').textContent())?.replace(/\s/g, ''), 'proof+1', 'the Event Tag line uses a leaf chip and +N');
  • assert.equal((await taskBlock.locator('.b-title').textContent())?.trim(), 'Preview Task', 'the Task title line does not repeat its Tags');
  • assert.equal((await taskBlock.locator('.b-tags').textContent())?.trim(), 'proof', 'the Task Tag line uses the leaf name');
    await weekJournalForLift.scrollIntoViewIfNeeded();
    await captureHoverLiftStrip(page, weekJournalForLift, join(artifacts, 'week-hover-lift-frames.svg'));
    await page.emulateMedia({ reducedMotion: 'reduce' });
    @@ -163,7 +172,7 @@ try {
    let composer = page.getByRole('dialog', { name: 'Composer' });
    await composer.waitFor();
    const composerText = composer.getByRole('textbox', { name: 'Entry text' });
  • assert.equal(await composerText.inputValue(), '08:30 - 09:30 Preview Journal #area/proof');
  • assert.equal(await composerText.inputValue(), '08:30 - 09:30 Preview Journal #area/proof #area/selfcare');
    assert.equal(await composer.getByRole('group', { name: 'Recognized details' }).getByLabel('Start time 08:30. Change').count(), 1);
    assert.equal(await composer.getByRole('group', { name: 'Recognized details' }).getByRole('button', { name: 'Tag area/proof. Change tag' }).count(), 1);
    await page.keyboard.press('Escape');
    @@ -172,7 +181,7 @@ try {
    await page.keyboard.press('Enter');
    composer = page.getByRole('dialog', { name: 'Composer' });
    await composer.waitFor();
  • assert.equal(await composer.getByRole('textbox', { name: 'Entry text' }).inputValue(), '08:30 - 09:30 Preview Journal #area/proof');
  • assert.equal(await composer.getByRole('textbox', { name: 'Entry text' }).inputValue(), '08:30 - 09:30 Preview Journal #area/proof #area/selfcare');
    await page.keyboard.press('Escape');
    await composer.waitFor({ state: 'detached' });
    console.log('Calendar preview 421: double-click and Enter passed');
    @@ -190,6 +199,7 @@ try {
    await journalBlock.click({ button: 'right' });
    const itemMenu = page.getByRole('menu', { name: 'Calendar item actions' });
    await itemMenu.getByRole('menuitem', { name: 'Duplicate' }).waitFor();
  • assert.deepEqual(await itemMenu.getByRole('menuitem').allTextContents(), ['Copy link', 'Attach File', 'Voice Memo', 'Edit', 'Hide', 'Duplicate', 'Delete'], 'the right-click menu renders the Journal card action registry in order');
    await page.keyboard.press('Escape');
    await itemMenu.waitFor({ state: 'detached' });
    console.log('Calendar preview 421: item menu passed');
    @@ -200,7 +210,7 @@ try {
    await journalBlock.dblclick();
    composer = page.getByRole('dialog', { name: 'Composer' });
    await composer.waitFor();
  • await composer.getByRole('textbox', { name: 'Entry text' }).fill('08:30 - 10:00 Revised Preview Journal #area/proof');
  • await composer.getByRole('textbox', { name: 'Entry text' }).fill('08:30 - 10:00 Revised Preview Journal #area/proof #area/selfcare');
    await composer.getByRole('button', { name: 'Save changes' }).click();
    await composer.waitFor({ state: 'detached' });
    console.log('Calendar preview 421: Journal edit saved');
    @@ -212,7 +222,7 @@ try {
    for (const [item, expectedPrefix] of [
    // CalDAV emits UTC timestamps from local wall time; check source grammar
    // without assuming the host's UTC offset.
  •   [page.locator('[data-key^="event:"]', { hasText: 'Preview Event' }), /^\d{2}:\d{2} - \d{2}:\d{2} Preview Event #area\/proof$/],
    
  •   [page.locator('[data-key^="event:"]', { hasText: 'Preview Event' }), /^\d{2}:\d{2} - \d{2}:\d{2} Preview Event #area\/proof #area\/selfcare$/],
      [page.locator(`[data-key^="task:${task.task_id}"]`), 'Preview Task']
    
    ]) {
    const label = expectedPrefix instanceof RegExp ? 'Event' : 'Task';
    @@ -286,7 +296,7 @@ try {
    preview = page.locator('.cal-popover');
    await preview.waitFor();
    const attachmentPicker = page.waitForEvent('filechooser', { timeout: 5000 });
  • await preview.getByRole('button', { name: 'Add attachment' }).click();
  • await preview.getByRole('button', { name: 'Attach File' }).click();
    await (await attachmentPicker).setFiles({ name: 'preview-421-attachment.txt', mimeType: 'text/plain', buffer: Buffer.from('Calendar card attachment proof') });
    composer = page.getByRole('dialog', { name: 'Composer' });
    await composer.waitFor();
    @@ -308,7 +318,7 @@ try {
    await page.keyboard.press('Control+i');
    preview = page.locator('.cal-popover, .surface.popover-sheet');
    await preview.waitFor();
  • await preview.getByRole('button', { name: 'Record voice note' }).click();
  • await preview.getByRole('button', { name: 'Voice Memo' }).click();
    composer = page.getByRole('dialog', { name: 'Composer' });
    await composer.waitFor();
    await composer.getByRole('button', { name: 'Stop recording' }).waitFor();
    @@ -343,20 +353,46 @@ try {
    console.log(Calendar preview 421: opened ${kind} by Space);
    const label = kind === 'journal' ? 'Journal' : kind === 'event' ? 'Event' : 'Task';
    assert.equal(await preview.locator(article[aria-label="${label}"]).count(), 1, ${kind} uses the shared preview card);
  •   		assert.equal(await preview.getByRole('button', { name: /Open day/i }).count(), 0, `${kind} preview does not offer a redundant Open Day action`);
      		const inlineTag = preview.locator('.title .pill.tag');
    
  •   		if (kind === 'event') {
    
  •   			assert.equal(await inlineTag.count(), 1, 'an Event title Tag renders as a compact chip');
    
  •   			assert.equal((await inlineTag.textContent())?.trim(), '#area/proof');
    
  •   		assert.match(await preview.locator('.date-time').textContent() ?? '', /2026/, 'the hover card shows the absolute locale date');
    
  •   		if (kind === 'journal') {
    
  •   			assert.deepEqual(await preview.locator('.date-time .time-chip').allTextContents(), ['08:30', '10:00'], 'the Journal date row shows separate time chips');
    
  •   			assert.deepEqual(await preview.locator('.tags .pill.tag').allTextContents(), ['proof', 'selfcare'], 'Journal Tags show leaf chips without # or parent paths');
    
  •   			const actionGroups = preview.locator('.actions [role="group"]');
    
  •   			assert.deepEqual(await actionGroups.evaluateAll((groups) => groups.map((group) => group.getAttribute('aria-label'))), ['Journal attachment actions', 'Journal actions'], 'the bottom row has exactly two action pills in order');
    
  •   			assert.deepEqual(await actionGroups.nth(0).locator('[data-calendar-action]').allTextContents(), ['Attach File', 'Voice Memo']);
    
  •   			assert.deepEqual(await actionGroups.nth(1).locator('[data-calendar-action]').allTextContents(), ['Edit', 'Hide', 'Duplicate', 'Delete']);
    
  •   			const titleCopy = preview.getByRole('button', { name: 'Copy link to this Journal entry' });
    
  •   			assert.equal(await titleCopy.count(), 1, 'Copy link is on the title row');
    
  •   			await preview.locator('.title-row').hover();
    
  •   			await page.waitForFunction(() => getComputedStyle(document.querySelector('.title-copy')).opacity === '1');
    
  •   			assert.ok(await titleCopy.isVisible(), 'title-row hover reveals its Copy link action');
    
  •   			const documentRow = preview.locator('.attachment-list.rows .attachment-line').filter({ hasText: 'Document' }).first();
    
  •   			await documentRow.waitFor();
    
  •   			await documentRow.hover();
    
  •   			await page.waitForFunction((row) => getComputedStyle(row.querySelector('.attachment-actions')).opacity === '1', await documentRow.elementHandle());
    
  •   			assert.equal(await documentRow.locator('.attachment-name').textContent(), 'Document', 'attachment rows show their kind instead of the filename');
    
  •   			assert.equal(await documentRow.getByRole('button', { name: /Copy link to/ }).count(), 1, 'the attachment hover pill offers Copy link');
    
  •   			assert.equal(await documentRow.getByRole('button', { name: /Delete attachment/ }).count(), 1, 'the attachment hover pill offers Delete');
    
  •   			if ([390, 820, 1440].includes(width)) {
    
  •   				await capture(page, { path: join(artifacts, `card-journal-attachment-actions-${width}-${mode}.png`), expect: preview, target: documentRow, theme, animations: 'disabled', scale: 'device' });
    
  •   			}
    
  •   			assert.ok(await preview.locator('.attachment-list.rows .attachment-line').filter({ hasText: 'Voice Memo' }).count() > 0, 'audio attachments use the Voice Memo kind label');
    
  •   			assert.match(await preview.locator('.date-time').textContent() ?? '', /\b2026\b/);
    
  •   		} else if (kind === 'event') {
    
  •   			assert.deepEqual(await inlineTag.allTextContents(), ['proof', 'selfcare'], 'Event title Tags use leaf chips');
      			assert.equal(await preview.locator('.title').evaluate((node) => [...node.childNodes].some((child) => child.nodeType === Node.TEXT_NODE && child.textContent?.includes('#area/proof'))), false, 'the Event title does not keep a raw Tag token');
    
  •   			assert.deepEqual(await preview.locator('.date-time .time-chip').allTextContents(), ['15:00', '16:00'], 'Event date row shows separate time chips');
      		} else {
    
  •   			assert.equal(await preview.locator('.tags .pill.tag').count(), 1, `${kind} Tags keep their shared compact chip`);
    
  •   			assert.deepEqual(await preview.locator('.tags .pill.tag').allTextContents(), ['proof'], 'Task Tags show leaf chips without # or parent paths');
      		}
      		const stem = `card-${kind}-${width}-${mode}`;
      		await capture(page, { path: join(artifacts, `${stem}.png`), expect: preview, target: preview, theme, animations: 'disabled', scale: 'device' });
    
  •   		const actionButton = kind === 'journal' ? preview.getByRole('button', { name: 'Add attachment' }) : preview.getByRole('button', { name: 'Duplicate' });
    
  •   		const actionButton = kind === 'journal' ? preview.getByRole('button', { name: 'Attach File' }) : preview.getByRole('button', { name: 'Duplicate' });
      		await actionButton.hover();
      		await page.locator('.warm-tooltip.visible').waitFor();
    
  •   		const actionLabel = kind === 'journal' ? 'Add attachment' : 'Duplicate';
    
  •   		const actionLabel = kind === 'journal' ? 'Attach File' : 'Duplicate';
      		assert.equal(await page.locator('.warm-tooltip.visible .warm-tooltip-label').textContent(), actionLabel);
      		assert.ok(await actionButton.getAttribute('aria-keyshortcuts'), `${actionLabel} exposes its shortcut to assistive technology`);
      		const actionBox = await preview.locator('.actions').boundingBox();
    

@@ -406,7 +442,7 @@ try {
await journal.dblclick();
composer = page.getByRole('dialog', { name: 'Composer' });
await composer.waitFor();

  •   	await composer.getByRole('textbox', { name: 'Entry text' }).fill('08:30 - 10:00 Preview Journal #area/proof\nEdit body proof');
    
  •   	await composer.getByRole('textbox', { name: 'Entry text' }).fill('08:30 - 10:00 Preview Journal #area/proof #area/selfcare\nEdit body proof');
      	await composer.getByRole('button', { name: 'Turn into note' }).waitFor();
      	await capture(page, {
      		path: join(artifacts, `composer-journal-${width}-${mode}.png`),
    

@@ -445,7 +481,7 @@ try {
await touchPage.getByRole('dialog', { name: 'Composer' }).waitFor();
assert.equal(
await touchPage.getByRole('dialog', { name: 'Composer' }).getByRole('textbox', { name: 'Entry text' }).inputValue(),

  •   '08:30 - 10:00 Revised Preview Journal #area/proof',
    
  •   '08:30 - 10:00 Revised Preview Journal #area/proof #area/selfcare',
      'a touch double-tap edits the selected Journal entry'
    
    );
    await touchPage.keyboard.press('Escape');
    diff --git a/apps/web/e2e/calendar.mjs b/apps/web/e2e/calendar.mjs
    index 3efa9ab9f..ad762f9f9 100644
    --- a/apps/web/e2e/calendar.mjs
    +++ b/apps/web/e2e/calendar.mjs
    @@ -411,7 +411,7 @@ async function runLogAttachmentProof(page, server, today, timezoneId, calendarSh
    const missingFile = findFile(server.userData, 'deleted-scan.pdf');
    assert.ok(missingFile, 'the linked file exists before the outside-delete test');
    await page.goto(${server.base}/d/${attachmentDay}#^${proof.id});
  • const preview = page.getByRole('dialog', { name: 'Log entry' });
  • const preview = page.getByRole('dialog', { name: 'Journal' });
    await preview.waitFor();
    const photoChip = preview.getByRole('button', { name: 'Open Photo · 16:33' });
    await photoChip.waitFor();
    @@ -489,7 +489,7 @@ async function runLogAttachmentProof(page, server, today, timezoneId, calendarSh
    const bulkBlock = page.locator('.block.actual', { hasText: 'Thousand attachment import' });
    await bulkBlock.waitFor();
    await bulkBlock.click();
  • const bulkPreview = page.getByRole('dialog', { name: 'Log entry' });
  • const bulkPreview = page.getByRole('dialog', { name: 'Journal' });
    await bulkPreview.waitFor();
    assert.equal(await bulkPreview.locator('.attachment-row').count(), 32, 'the preview mounts one page, not all 1000 child cards');
    await bulkPreview.getByText('1–32 of 1000', { exact: true }).waitFor();
    diff --git a/apps/web/e2e/composer.mjs b/apps/web/e2e/composer.mjs
    index 4b31da9ab..ba0006233 100644
    --- a/apps/web/e2e/composer.mjs
    +++ b/apps/web/e2e/composer.mjs
    @@ -787,7 +787,7 @@ try {
    const livePhotoEntry = page.locator('.block.actual', { hasText: 'morning run' });
    await livePhotoEntry.waitFor();
    await livePhotoEntry.click();
  • const livePhotoPreview = page.getByRole('dialog', { name: 'Log entry' });
  • const livePhotoPreview = page.getByRole('dialog', { name: 'Journal' });
    await livePhotoPreview.waitFor();
    await livePhotoPreview.getByRole('button', { name: /Open (run.png|Photo ·)/ }).waitFor();
    await page.keyboard.press('Escape');
    diff --git a/apps/web/e2e/motion-spring-evidence.mjs b/apps/web/e2e/motion-spring-evidence.mjs
    index 7c340dafd..9df609cf7 100644
    --- a/apps/web/e2e/motion-spring-evidence.mjs
    +++ b/apps/web/e2e/motion-spring-evidence.mjs
    @@ -746,7 +746,7 @@ async function exerciseMotion(page, cdp, base, theme, profile) {
    await page.goto(${base}/calendar/day/${localDate()});
    const calendarBlock = page.locator('.block.actual', { hasText: 'Motion spring evidence log' });
    await calendarBlock.waitFor({ state: 'visible' });
  • const calendarPreview = page.getByRole('dialog', { name: 'Log entry' });
  • const calendarPreview = page.getByRole('dialog', { name: 'Journal' });
    await captureTransition(page, profile, theme, 'calendar-preview-open', async () => {
    await calendarBlock.hover();
    await calendarPreview.waitFor();
    diff --git a/apps/web/e2e/pill-feedback.mjs b/apps/web/e2e/pill-feedback.mjs
    index 28729cb68..8e9a7090a 100644
    --- a/apps/web/e2e/pill-feedback.mjs
    +++ b/apps/web/e2e/pill-feedback.mjs
    @@ -330,7 +330,7 @@ async function openLogPreview(page, base, date, title = LOG_TITLE) {
    await entry.focus();
    await page.keyboard.press('Enter');
    }
  • const preview = page.getByRole('article', { name: 'Log entry' });
  • const preview = page.getByRole('article', { name: 'Journal' });
    await preview.waitFor({ state: 'visible' });
    // Leaving the Calendar anchor changes its hover box; settle the 180 ms popover move before reading pill bounds.
    await page.mouse.move(1, 1);
    diff --git a/apps/web/src/lib/calendar/ItemPreview.svelte.test.ts b/apps/web/src/lib/calendar/ItemPreview.svelte.test.ts
    index cbe7da70d..7d99504fa 100644
    --- a/apps/web/src/lib/calendar/ItemPreview.svelte.test.ts
    +++ b/apps/web/src/lib/calendar/ItemPreview.svelte.test.ts
    @@ -4,7 +4,7 @@
    */
    import { fireEvent, render, screen } from '@testing-library/svelte';
    import { describe, expect, it, vi } from 'vitest';
    -import { ItemPreview } from '@calternal/ui';
    +import { ItemPreview, calendarItemActions } from '@calternal/ui';
    import type { PreviewItem } from '@calternal/ui';

describe('Calendar item preview labels', () => {
@@ -26,7 +26,7 @@ describe('Calendar item preview labels', () => {

	expect(screen.getByRole('article').getAttribute('aria-label')).toBe('Journal');
	expect(screen.getByText('Journal')).toBeTruthy();
  •   expect(screen.getByRole('group', { name: 'Journal actions' })).toBeTruthy();
    
  •   expect(screen.getByRole('button', { name: 'Copy link to this Journal entry' })).toBeTruthy();
    

    });

    it.each([
    @@ -49,10 +49,11 @@ describe('Calendar item preview labels', () => {
    const { container } = render(ItemPreview, { item });
    const heading = screen.getByRole('heading');
    const titleChip = heading.querySelector('.pill.tag');

  •   expect(titleChip?.textContent).toBe('#area/proof');
    
  •   expect(titleChip?.textContent).toBe('proof');
    
  •   expect(titleChip?.getAttribute('aria-label')).toBe('area/proof');
      expect([...heading.childNodes].some((node) => node.nodeType === Node.TEXT_NODE && node.textContent?.includes('#area/proof'))).toBe(false);
      expect(container.querySelectorAll('.tags .pill.tag')).toHaveLength(1);
    
  •   expect(container.querySelector('.tags')?.textContent).toContain('#work');
    
  •   expect(container.querySelector('.tags')?.textContent).toContain('work');
    

    });

    it('normalizes Unicode title Tags before matching their metadata chips', () => {
    @@ -63,10 +64,64 @@ describe('Calendar item preview labels', () => {
    };

      const { container } = render(ItemPreview, { item });
    
  •   expect([...container.querySelectorAll('.title .pill.tag')].map((chip) => chip.textContent)).toEqual(['#é', '#作業']);
    
  •   expect([...container.querySelectorAll('.title .pill.tag')].map((chip) => chip.textContent)).toEqual(['é', '作業']);
    
  •   expect([...container.querySelectorAll('.title .pill.tag')].map((chip) => chip.getAttribute('title'))).toEqual(['é', '作業']);
      expect(container.querySelector('.tags')).toBeNull();
    

    });

  • it('renders the Journal date, time chips, leaf Tags and two ordered action groups', async () => {

  •   const item: PreviewItem = {
    
  •   	kind: 'log',
    
  •   	date: '2026-09-29',
    
  •   	log: { id: 'log-card', start: '15:20', end: '16:18', title: 'Self care', tags: ['area/selfcare', 'area/health'], attachments: [] }
    
  •   };
    
  •   const callbacks = {
    
  •   	onattachment: vi.fn(),
    
  •   	onrecordvoice: vi.fn(),
    
  •   	onedit: vi.fn(),
    
  •   	onhide: vi.fn(),
    
  •   	onduplicate: vi.fn(),
    
  •   	ondelete: vi.fn()
    
  •   };
    
  •   const { container } = render(ItemPreview, { item, ...callbacks });
    
  •   expect(screen.getByRole('article', { name: 'Journal' })).toBeTruthy();
    
  •   expect(container.querySelector('.date-time')?.textContent).toContain('2026');
    
  •   expect([...container.querySelectorAll('.date-time .time-chip')].map((chip) => chip.textContent?.trim())).toEqual(['3:20 PM', '4:18 PM']);
    
  •   const tag = container.querySelector('.tags .pill.tag');
    
  •   expect(tag?.textContent).toBe('selfcare');
    
  •   expect(tag?.getAttribute('title')).toBe('area/selfcare');
    
  •   expect(screen.getByRole('button', { name: 'Copy link to this Journal entry' })).toBeTruthy();
    
  •   expect([...container.querySelectorAll('.actions > [role="group"]')].map((group) => group.getAttribute('aria-label'))).toEqual([
    
  •   	'Journal attachment actions',
    
  •   	'Journal actions'
    
  •   ]);
    
  •   expect([...container.querySelectorAll('.actions [data-calendar-action]')].map((button) => button.getAttribute('data-calendar-action'))).toEqual([
    
  •   	'attach-file', 'voice-memo', 'edit', 'hide', 'duplicate', 'delete'
    
  •   ]);
    
  •   await fireEvent.click(screen.getByRole('button', { name: 'Hide' }));
    
  •   expect(callbacks.onhide).toHaveBeenCalledOnce();
    
  • });

  • it('provides one ordered action definition for the card and right-click menu', () => {

  •   const item: PreviewItem = {
    
  •   	kind: 'log',
    
  •   	date: '2026-09-29',
    
  •   	log: { id: 'log-actions', start: '08:30', end: null, title: 'Review', tags: [], attachments: [] }
    
  •   };
    
  •   expect(calendarItemActions(item, {
    
  •   	'copy-link': true,
    
  •   	'attach-file': true,
    
  •   	'voice-memo': true,
    
  •   	edit: true,
    
  •   	hide: true,
    
  •   	duplicate: true,
    
  •   	delete: true
    
  •   }).map((action) => action.id)).toEqual([
    
  •   	'copy-link', 'attach-file', 'voice-memo', 'edit', 'hide', 'duplicate', 'delete'
    
  •   ]);
    
  • });

  • it.each([
    {
    kind: 'log',
    diff --git a/apps/web/src/lib/webmcp/tools.ts b/apps/web/src/lib/webmcp/tools.ts
    index 6866f7955..246b7a6c2 100644
    --- a/apps/web/src/lib/webmcp/tools.ts
    +++ b/apps/web/src/lib/webmcp/tools.ts
    @@ -81,7 +81,7 @@ function calendarEnum(value: string | undefined, values: r
    return value as Value;
    }

-export type ConfirmWrite = (action: 'Change data' | 'mail_read_state' | 'mail_read_marking' | 'mail_category' | 'mail_remote_content' | 'mail_save_attachment' | 'Create a Journal entry' | 'Create a Log entry' | 'Create a Task' | 'Create a Note' | 'Complete the Task' | 'Reopen the Task' | 'Duplicate an item' | 'Add attachments to a Journal entry' | 'Create a Calendar feed' | 'Update a Calendar feed' | 'Rotate a Calendar feed link' | 'Revoke a Calendar feed link' | 'Delete a Calendar feed' | 'Add an external Calendar' | 'Update an external Calendar' | 'Refresh an external Calendar' | 'Remove an external Calendar', summary: string) => Promise;
+export type ConfirmWrite = (action: 'Change data' | 'mail_read_state' | 'mail_read_marking' | 'mail_category' | 'mail_remote_content' | 'mail_save_attachment' | 'Create a Journal entry' | 'Create a Task' | 'Create a Note' | 'Complete the Task' | 'Reopen the Task' | 'Duplicate an item' | 'Add attachments to a Journal entry' | 'Create a Calendar feed' | 'Update a Calendar feed' | 'Rotate a Calendar feed link' | 'Revoke a Calendar feed link' | 'Delete a Calendar feed' | 'Add an external Calendar' | 'Update an external Calendar' | 'Refresh an external Calendar' | 'Remove an external Calendar', summary: string) => Promise;

function fields(input: unknown, allowed: readonly string[]): Record<string, unknown> {
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Invalid tool input.');
diff --git a/apps/web/src/routes/calendar/[view]/[date]/+page.svelte b/apps/web/src/routes/calendar/[view]/[date]/+page.svelte
index e364c55b5..3584c7ec3 100644
--- a/apps/web/src/routes/calendar/[view]/[date]/+page.svelte
+++ b/apps/web/src/routes/calendar/[view]/[date]/+page.svelte
@@ -54,6 +54,7 @@
TimeGrid,
YearHeatmap,
activityCount,

  •   calendarItemActions,
      addDays,
      	addMonths,
      	fromMinutes,
    

@@ -143,8 +144,15 @@
import { calendarSidebar } from '$lib/calendar/sidebarState.svelte';
import { usePageChrome } from '$lib/chrome.svelte';
import PlusIcon from '@lucide/svelte/icons/plus';

  • import CalendarIcon from '@lucide/svelte/icons/calendar';
    import InfoIcon from '@lucide/svelte/icons/info';
    import LinkIcon from '@lucide/svelte/icons/link';
  • import PaperclipIcon from '@lucide/svelte/icons/paperclip';
  • import MicIcon from '@lucide/svelte/icons/mic';
  • import PencilIcon from '@lucide/svelte/icons/pencil';
  • import EyeOffIcon from '@lucide/svelte/icons/eye-off';
  • import CopyIcon from '@lucide/svelte/icons/copy';
  • import TrashIcon from '@lucide/svelte/icons/trash-2';
    import { toast, UNDO_MS } from '$lib/stores/toasts.svelte';
    import { composer, openComposer } from '$lib/composer/controller.svelte';
    import { composerVoiceRecorder } from '$lib/composer/recorder.svelte';
    @@ -828,8 +836,8 @@
    });
    }
  • /** Remove a stale file link through Notes so the Markdown and Calendar update together (#427). */
  • function removeMissingAttachment(date: string, log: CalendarLog, attachment: CalendarAttachment): void {
  • /** Remove one attachment link through Notes so the Daily note and Calendar update together (#427, #628). */
  • function removeJournalAttachment(date: string, log: CalendarLog, attachment: CalendarAttachment): void {
    if (!log.id) return;
    closePreview();
    void removeLogAttachment(date, log.id, attachment.target).then(() => {
    @@ -878,7 +886,7 @@
    const id = log.id!;
    return {
    ondelete: () => deleteLog(day, log),
  •   	onremoveattachment: (attachment: CalendarAttachment) => removeMissingAttachment(day, log, attachment),
    
  •   	onremoveattachment: (attachment: CalendarAttachment) => removeJournalAttachment(day, log, attachment),
      	onnote: (title: string, body: string) => logToNote(day, log, title, body),
      	noteId: noteIds.get(id) ?? null
      };
    

@@ -1202,7 +1210,7 @@
/** Build the stable deep link for each Calendar item kind (DESIGN §33). */
function contextHref(item: PreviewItem): string | null {
if (item.kind === 'log') return logEntryHref(item.date, item.log.id);

  •   if (item.kind === 'event') return eventHref(item.event.id);
    
  •   if (item.kind === 'event') return eventHref(item.event.id, item.date, item.event.sourceId);
      if (item.kind === 'task') return taskHref(item.task.id);
      return null;
    
    }
    @@ -1233,17 +1241,22 @@
    openItemContext(target, x, y);
    }
  • /** Dispatch shared item-menu actions without duplicating their write paths (#421; DESIGN §41). */
  • /** Dispatch the card and context menu through the same Calendar item action IDs (#581, #628). */
    function runItemContext(action: string): void {
    const context = itemContext;
    if (!context) return;
  •   if (action === 'preview') {
    
  •   	selected = { item: context.item, anchor: context.anchor, key: context.key };
    
  •   	openPreview(context.item, context.anchor, context.key);
    
  •   } else if (action === 'edit') editItem(context.item, context.anchor, context.key);
    
  •   if (action === 'edit') editItem(context.item, context.anchor, context.key);
      else if (action === 'duplicate') void duplicateItem(context.item);
    
  •   else if (action === 'day') openDay(context.item.date);
    
  •   else if (action === 'copy') {
    
  •   else if (action === 'hide') {
    
  •   	closePreview();
    
  •   	closeItemContext();
    
  •   } else if (action === 'attach-file' && context.item.kind === 'log') {
    
  •   	chooseLogAttachments(context.item, context.anchor, context.key);
    
  •   } else if (action === 'voice-memo' && context.item.kind === 'log') {
    
  •   	recordLogVoice(context.item, context.anchor, context.key);
    
  •   } else if (action === 'delete' && context.item.kind === 'log') deleteLog(context.item.date, context.item.log);
    
  •   else if (action === 'copy-to-calendar' && context.item.kind === 'event') void copySubscribedEvent(context.item.event, context.item.date);
    
  •   else if (action === 'copy-link') {
      	const href = contextHref(context.item);
      	if (href) void copyLinkWithToast(href);
      }
    

@@ -1256,18 +1269,44 @@
if (anchor?.isConnected && !anchor.contains(document.activeElement)) anchor.focus({ preventScroll: true });
}

  • /** Build the same accessible actions for Agenda, grid, Month and touch (#421). */
  • /** Build the shared action list for Calendar cards and right-click menus (#581, #628). */
    function itemContextItems(item: PreviewItem): MenuNode[] {
  •   const canDuplicate = item.kind !== 'log' || !!item.log.id;
    
  •   const label = item.kind === 'log' ? 'Journal entry' : item.kind === 'event' ? 'Event' : 'Task';
    
  •   return [
    
  •   	{ id: 'edit', label: `Edit ${label}` },
    
  •   	{ id: 'preview', label: 'Show preview', kbd: ['cmd', 'i'] },
    
  •   	...(canDuplicate ? [{ id: 'duplicate', label: 'Duplicate', shortcut: 'calendar.duplicate' as const }] : []),
    
  •   	{ id: 'day', label: 'Open day' },
    
  •   	{ type: 'separator' },
    
  •   	{ id: 'copy', label: item.kind === 'log' ? 'Copy link to this Journal entry' : item.kind === 'event' ? 'Copy link to this Event' : 'Copy link to this Task' }
    
  •   ];
    
  •   if (item.kind !== 'log' && item.kind !== 'event' && item.kind !== 'task') return [];
    
  •   const stable = item.kind === 'log' ? !!item.log.id : true;
    
  •   const actions = calendarItemActions(item, {
    
  •   	'copy-link': stable,
    
  •   	'attach-file': item.kind === 'log' && stable,
    
  •   	'voice-memo': item.kind === 'log' && stable,
    
  •   	edit: stable && !(item.kind === 'event' && item.event.readOnly),
    
  •   	hide: true,
    
  •   	duplicate: stable,
    
  •   	'copy-to-calendar': item.kind === 'event' && item.event.readOnly,
    
  •   	delete: item.kind === 'log' && stable
    
  •   });
    
  •   const icons = {
    
  •   	'copy-link': LinkIcon,
    
  •   	'attach-file': PaperclipIcon,
    
  •   	'voice-memo': MicIcon,
    
  •   	edit: PencilIcon,
    
  •   	hide: EyeOffIcon,
    
  •   	duplicate: CopyIcon,
    
  •   	'copy-to-calendar': CalendarIcon,
    
  •   	delete: TrashIcon
    
  •   };
    
  •   const nodes: MenuNode[] = [];
    
  •   let previousGroup = '';
    
  •   for (const action of actions) {
    
  •   	if (previousGroup && previousGroup !== action.group) nodes.push({ type: 'separator' });
    
  •   	nodes.push({
    
  •   		id: action.id,
    
  •   		label: action.label,
    
  •   		icon: icons[action.id],
    
  •   		shortcut: action.shortcut,
    
  •   		danger: action.destructive
    
  •   	});
    
  •   	previousGroup = action.group;
    
  •   }
    
  •   return nodes;
    

    }

    /** Turn a second touch on the same item into edit before its click selects again (#421; DESIGN §34). */
    @@ -2047,7 +2086,7 @@
    oncopyevent={copySubscribedEvent}
    oneditstart={() => primeKeyboard({ inputType: 'text', inputMode: 'text' })}
    ontag={(tag) => goto(tagHref(tag))}

  •   		onopenday={view === 'day' ? undefined : openDay}
    
  •   		onhide={closePreview}
      		onnavigate={(href) => void goto(href)}
      		onclose={closePreview}
      		{...logActions(currentPreview.item)}
    

diff --git a/packages/ui/src/components/TagPill.svelte b/packages/ui/src/components/TagPill.svelte
index 2a5855b44..51e491e80 100644
--- a/packages/ui/src/components/TagPill.svelte
+++ b/packages/ui/src/components/TagPill.svelte
@@ -2,8 +2,10 @@

{#if onclick}
@@ -47,12 +52,14 @@
style="--tfg:{c.fg}; --tbg:{c.bg}"
use:pillFeedback
onclick={() => onclick?.(tag)}

  • title={leafOnly ? tag : undefined}
  • aria-label={leafOnly ? tag : undefined}
    aria-haspopup={haspopup}
    aria-expanded={haspopup ? expanded : undefined}
    aria-pressed={haspopup ? undefined : active}
  • #{tag}

  • {text}
    {:else}

  • <span class="pill tag" class:md={size === 'md'} style="--tfg:{c.fg}; --tbg:{c.bg}">#{tag}
  • <span class="pill tag" class:md={size === 'md'} style="--tfg:{c.fg}; --tbg:{c.bg}" title={leafOnly ? tag : undefined} aria-label={leafOnly ? tag : undefined}>{text}
    {/if}
Finding: commit cd435e354d78ff44e225e5bfdab91db4179fb18a Author: kayg <mail@kayg.org> Date: Thu Oct 1 14:12:04 2026 +0200 Polish Calendar blocks and Journal preview actions commit a0eff3e99d965099a34f94de0fefeb1af90168da Author: kayg <mail@kayg.org> Date: Sat Sep 26 21:03:43 2026 +0200 feat(web): support block actions in editor buttons and commit cd435e354d78ff44e225e5bfdab91db4179fb18a Author: kayg <mail@kayg.org> Date: Thu Oct 1 14:12:04 2026 +0200 Polish Calendar blocks and Journal preview actions diff --git a/apps/web/e2e/calendar-crossday.mjs b/apps/web/e2e/calendar-crossday.mjs index aba498958..c84b912f3 100644 --- a/apps/web/e2e/calendar-crossday.mjs +++ b/apps/web/e2e/calendar-crossday.mjs @@ -200,9 +200,12 @@ async function waitForView(page, view, records, sourceDate) { if (view.name === 'week-source' && record.title === 'Midnight continuation') { await first.click(); assert.equal(await page.locator(`.block.actual.selected[data-key="${key}"]`).count(), 2, 'selecting either Log segment selects the same Week item'); - const preview = page.locator('article.preview[aria-label="Log entry"]'); + const preview = page.locator('article.preview[aria-label="Journal"]'); await preview.waitFor(); - assert.match(await preview.locator('.meta').textContent(), /23:58\s*–\s*00:30/, 'either segment previews the full source Log range'); + const sourceTimes = await preview.locator('.date-time .time-chip').allTextContents(); + assert.equal(sourceTimes.length, 2, 'either segment previews separate start and end time chips'); + assert.match(sourceTimes[0], /23:58|11:58/); + assert.match(sourceTimes[1], /00:30|12:30/); } } } diff --git a/apps/web/e2e/calendar-preview-421.mjs b/apps/web/e2e/calendar-preview-421.mjs index 5018f659b..36003bc4b 100644 --- a/apps/web/e2e/calendar-preview-421.mjs +++ b/apps/web/e2e/calendar-preview-421.mjs @@ -84,7 +84,7 @@ try { const response = await fetch('/api/v1/notes/journal/log', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ text: '08:30 - 09:30 Preview Journal #area/proof', now: new Date(year, month - 1, date, 8, 30).toISOString(), timezone: zone }) + body: JSON.stringify({ text: '08:30 - 09:30 Preview Journal #area/proof #area/selfcare', now: new Date(year, month - 1, date, 8, 30).toISOString(), timezone: zone }) }); const body = await response.json(); if (!response.ok) throw new Error(`Journal fixture ${response.status}: ${JSON.stringify(body)}`); @@ -102,7 +102,7 @@ try { }, { day: today }); caldav = await startCalDAV(today, { - timedEvents: [{ date: today, start: '15:00', end: '16:00', uid: 'e2e-preview-event', title: 'Preview Event #area/proof' }] + timedEvents: [{ date: today, start: '15:00', end: '16:00', uid: 'e2e-preview-event', title: 'Preview Event #area/proof #area/selfcare' }] }); await connectCalDAV(page, server.base, caldav); const route = `/calendar/day/${today}`; @@ -127,6 +127,15 @@ try { await page.mouse.move(6, 6); const weekJournalForLift = page.locator(`[data-key^="log:${today}:${journal.id}"]`); await weekJournalForLift.waitFor(); + assert.equal((await weekJournalForLift.locator('.b-time-row .b-time-chip').allTextContents()).join(','), '08:30,09:30', 'Week Journal shows a clock row with separate start and end chips'); + assert.equal((await weekJournalForLift.locator('.b-title').textContent())?.trim(), 'Preview Journal', 'the Journal title has its own grid line'); + assert.equal((await weekJournalForLift.locator('.b-tags').textContent())?.replace(/\s/g, ''), 'proof+1', 'the grid shows a leaf Tag chip and collapses the rest to +N'); + assert.equal(await weekJournalForLift.locator('.b-tags').getAttribute('title'), 'area/proof, area/selfcare', 'the grid chip keeps full Tags in its tooltip'); + assert.deepEqual(await eventBlock.locator('.b-time-row .b-time-chip').allTextContents(), ['15:00', '16:00'], 'Week Event shows its time range as chips above the title'); + assert.equal((await eventBlock.locator('.b-title').textContent())?.trim(), 'Preview Event', 'the Event title line does not repeat its Tags'); + assert.equal((await eventBlock.locator('.b-tags').textContent())?.replace(/\s/g, ''), 'proof+1', 'the Event Tag line uses a leaf chip and +N'); + assert.equal((await taskBlock.locator('.b-title').textContent())?.trim(), 'Preview Task', 'the Task title line does not repeat its Tags'); + assert.equal((await taskBlock.locator('.b-tags').textContent())?.trim(), 'proof', 'the Task Tag line uses the leaf name'); await weekJournalForLift.scrollIntoViewIfNeeded(); await captureHoverLiftStrip(page, weekJournalForLift, join(artifacts, 'week-hover-lift-frames.svg')); await page.emulateMedia({ reducedMotion: 'reduce' }); @@ -163,7 +172,7 @@ try { let composer = page.getByRole('dialog', { name: 'Composer' }); await composer.waitFor(); const composerText = composer.getByRole('textbox', { name: 'Entry text' }); - assert.equal(await composerText.inputValue(), '08:30 - 09:30 Preview Journal #area/proof'); + assert.equal(await composerText.inputValue(), '08:30 - 09:30 Preview Journal #area/proof #area/selfcare'); assert.equal(await composer.getByRole('group', { name: 'Recognized details' }).getByLabel('Start time 08:30. Change').count(), 1); assert.equal(await composer.getByRole('group', { name: 'Recognized details' }).getByRole('button', { name: 'Tag area/proof. Change tag' }).count(), 1); await page.keyboard.press('Escape'); @@ -172,7 +181,7 @@ try { await page.keyboard.press('Enter'); composer = page.getByRole('dialog', { name: 'Composer' }); await composer.waitFor(); - assert.equal(await composer.getByRole('textbox', { name: 'Entry text' }).inputValue(), '08:30 - 09:30 Preview Journal #area/proof'); + assert.equal(await composer.getByRole('textbox', { name: 'Entry text' }).inputValue(), '08:30 - 09:30 Preview Journal #area/proof #area/selfcare'); await page.keyboard.press('Escape'); await composer.waitFor({ state: 'detached' }); console.log('Calendar preview 421: double-click and Enter passed'); @@ -190,6 +199,7 @@ try { await journalBlock.click({ button: 'right' }); const itemMenu = page.getByRole('menu', { name: 'Calendar item actions' }); await itemMenu.getByRole('menuitem', { name: 'Duplicate' }).waitFor(); + assert.deepEqual(await itemMenu.getByRole('menuitem').allTextContents(), ['Copy link', 'Attach File', 'Voice Memo', 'Edit', 'Hide', 'Duplicate', 'Delete'], 'the right-click menu renders the Journal card action registry in order'); await page.keyboard.press('Escape'); await itemMenu.waitFor({ state: 'detached' }); console.log('Calendar preview 421: item menu passed'); @@ -200,7 +210,7 @@ try { await journalBlock.dblclick(); composer = page.getByRole('dialog', { name: 'Composer' }); await composer.waitFor(); - await composer.getByRole('textbox', { name: 'Entry text' }).fill('08:30 - 10:00 Revised Preview Journal #area/proof'); + await composer.getByRole('textbox', { name: 'Entry text' }).fill('08:30 - 10:00 Revised Preview Journal #area/proof #area/selfcare'); await composer.getByRole('button', { name: 'Save changes' }).click(); await composer.waitFor({ state: 'detached' }); console.log('Calendar preview 421: Journal edit saved'); @@ -212,7 +222,7 @@ try { for (const [item, expectedPrefix] of [ // CalDAV emits UTC timestamps from local wall time; check source grammar // without assuming the host's UTC offset. - [page.locator('[data-key^="event:"]', { hasText: 'Preview Event' }), /^\d{2}:\d{2} - \d{2}:\d{2} Preview Event #area\/proof$/], + [page.locator('[data-key^="event:"]', { hasText: 'Preview Event' }), /^\d{2}:\d{2} - \d{2}:\d{2} Preview Event #area\/proof #area\/selfcare$/], [page.locator(`[data-key^="task:${task.task_id}"]`), 'Preview Task'] ]) { const label = expectedPrefix instanceof RegExp ? 'Event' : 'Task'; @@ -286,7 +296,7 @@ try { preview = page.locator('.cal-popover'); await preview.waitFor(); const attachmentPicker = page.waitForEvent('filechooser', { timeout: 5000 }); - await preview.getByRole('button', { name: 'Add attachment' }).click(); + await preview.getByRole('button', { name: 'Attach File' }).click(); await (await attachmentPicker).setFiles({ name: 'preview-421-attachment.txt', mimeType: 'text/plain', buffer: Buffer.from('Calendar card attachment proof') }); composer = page.getByRole('dialog', { name: 'Composer' }); await composer.waitFor(); @@ -308,7 +318,7 @@ try { await page.keyboard.press('Control+i'); preview = page.locator('.cal-popover, .surface.popover-sheet'); await preview.waitFor(); - await preview.getByRole('button', { name: 'Record voice note' }).click(); + await preview.getByRole('button', { name: 'Voice Memo' }).click(); composer = page.getByRole('dialog', { name: 'Composer' }); await composer.waitFor(); await composer.getByRole('button', { name: 'Stop recording' }).waitFor(); @@ -343,20 +353,46 @@ try { console.log(`Calendar preview 421: opened ${kind} by Space`); const label = kind === 'journal' ? 'Journal' : kind === 'event' ? 'Event' : 'Task'; assert.equal(await preview.locator(`article[aria-label="${label}"]`).count(), 1, `${kind} uses the shared preview card`); + assert.equal(await preview.getByRole('button', { name: /Open day/i }).count(), 0, `${kind} preview does not offer a redundant Open Day action`); const inlineTag = preview.locator('.title .pill.tag'); - if (kind === 'event') { - assert.equal(await inlineTag.count(), 1, 'an Event title Tag renders as a compact chip'); - assert.equal((await inlineTag.textContent())?.trim(), '#area/proof'); + assert.match(await preview.locator('.date-time').textContent() ?? '', /2026/, 'the hover card shows the absolute locale date'); + if (kind === 'journal') { + assert.deepEqual(await preview.locator('.date-time .time-chip').allTextContents(), ['08:30', '10:00'], 'the Journal date row shows separate time chips'); + assert.deepEqual(await preview.locator('.tags .pill.tag').allTextContents(), ['proof', 'selfcare'], 'Journal Tags show leaf chips without # or parent paths'); + const actionGroups = preview.locator('.actions [role="group"]'); + assert.deepEqual(await actionGroups.evaluateAll((groups) => groups.map((group) => group.getAttribute('aria-label'))), ['Journal attachment actions', 'Journal actions'], 'the bottom row has exactly two action pills in order'); + assert.deepEqual(await actionGroups.nth(0).locator('[data-calendar-action]').allTextContents(), ['Attach File', 'Voice Memo']); + assert.deepEqual(await actionGroups.nth(1).locator('[data-calendar-action]').allTextContents(), ['Edit', 'Hide', 'Duplicate', 'Delete']); + const titleCopy = preview.getByRole('button', { name: 'Copy link to this Journal entry' }); + assert.equal(await titleCopy.count(), 1, 'Copy link is on the title row'); + await preview.locator('.title-row').hover(); + await page.waitForFunction(() => getComputedStyle(document.querySelector('.title-copy')).opacity === '1'); + assert.ok(await titleCopy.isVisible(), 'title-row hover reveals its Copy link action'); + const documentRow = preview.locator('.attachment-list.rows .attachment-line').filter({ hasText: 'Document' }).first(); + await documentRow.waitFor(); + await documentRow.hover(); + await page.waitForFunction((row) => getComputedStyle(row.querySelector('.attachment-actions')).opacity === '1', await documentRow.elementHandle()); + assert.equal(await documentRow.locator('.attachment-name').textContent(), 'Document', 'attachment rows show their kind instead of the filename'); + assert.equal(await documentRow.getByRole('button', { name: /Copy link to/ }).count(), 1, 'the attachment hover pill offers Copy link'); + assert.equal(await documentRow.getByRole('button', { name: /Delete attachment/ }).count(), 1, 'the attachment hover pill offers Delete'); + if ([390, 820, 1440].includes(width)) { + await capture(page, { path: join(artifacts, `card-journal-attachment-actions-${width}-${mode}.png`), expect: preview, target: documentRow, theme, animations: 'disabled', scale: 'device' }); + } + assert.ok(await preview.locator('.attachment-list.rows .attachment-line').filter({ hasText: 'Voice Memo' }).count() > 0, 'audio attachments use the Voice Memo kind label'); + assert.match(await preview.locator('.date-time').textContent() ?? '', /\b2026\b/); + } else if (kind === 'event') { + assert.deepEqual(await inlineTag.allTextContents(), ['proof', 'selfcare'], 'Event title Tags use leaf chips'); assert.equal(await preview.locator('.title').evaluate((node) => [...node.childNodes].some((child) => child.nodeType === Node.TEXT_NODE && child.textContent?.includes('#area/proof'))), false, 'the Event title does not keep a raw Tag token'); + assert.deepEqual(await preview.locator('.date-time .time-chip').allTextContents(), ['15:00', '16:00'], 'Event date row shows separate time chips'); } else { - assert.equal(await preview.locator('.tags .pill.tag').count(), 1, `${kind} Tags keep their shared compact chip`); + assert.deepEqual(await preview.locator('.tags .pill.tag').allTextContents(), ['proof'], 'Task Tags show leaf chips without # or parent paths'); } const stem = `card-${kind}-${width}-${mode}`; await capture(page, { path: join(artifacts, `${stem}.png`), expect: preview, target: preview, theme, animations: 'disabled', scale: 'device' }); - const actionButton = kind === 'journal' ? preview.getByRole('button', { name: 'Add attachment' }) : preview.getByRole('button', { name: 'Duplicate' }); + const actionButton = kind === 'journal' ? preview.getByRole('button', { name: 'Attach File' }) : preview.getByRole('button', { name: 'Duplicate' }); await actionButton.hover(); await page.locator('.warm-tooltip.visible').waitFor(); - const actionLabel = kind === 'journal' ? 'Add attachment' : 'Duplicate'; + const actionLabel = kind === 'journal' ? 'Attach File' : 'Duplicate'; assert.equal(await page.locator('.warm-tooltip.visible .warm-tooltip-label').textContent(), actionLabel); assert.ok(await actionButton.getAttribute('aria-keyshortcuts'), `${actionLabel} exposes its shortcut to assistive technology`); const actionBox = await preview.locator('.actions').boundingBox(); @@ -406,7 +442,7 @@ try { await journal.dblclick(); composer = page.getByRole('dialog', { name: 'Composer' }); await composer.waitFor(); - await composer.getByRole('textbox', { name: 'Entry text' }).fill('08:30 - 10:00 Preview Journal #area/proof\nEdit body proof'); + await composer.getByRole('textbox', { name: 'Entry text' }).fill('08:30 - 10:00 Preview Journal #area/proof #area/selfcare\nEdit body proof'); await composer.getByRole('button', { name: 'Turn into note' }).waitFor(); await capture(page, { path: join(artifacts, `composer-journal-${width}-${mode}.png`), @@ -445,7 +481,7 @@ try { await touchPage.getByRole('dialog', { name: 'Composer' }).waitFor(); assert.equal( await touchPage.getByRole('dialog', { name: 'Composer' }).getByRole('textbox', { name: 'Entry text' }).inputValue(), - '08:30 - 10:00 Revised Preview Journal #area/proof', + '08:30 - 10:00 Revised Preview Journal #area/proof #area/selfcare', 'a touch double-tap edits the selected Journal entry' ); await touchPage.keyboard.press('Escape'); diff --git a/apps/web/e2e/calendar.mjs b/apps/web/e2e/calendar.mjs index 3efa9ab9f..ad762f9f9 100644 --- a/apps/web/e2e/calendar.mjs +++ b/apps/web/e2e/calendar.mjs @@ -411,7 +411,7 @@ async function runLogAttachmentProof(page, server, today, timezoneId, calendarSh const missingFile = findFile(server.userData, 'deleted-scan.pdf'); assert.ok(missingFile, 'the linked file exists before the outside-delete test'); await page.goto(`${server.base}/d/${attachmentDay}#^${proof.id}`); - const preview = page.getByRole('dialog', { name: 'Log entry' }); + const preview = page.getByRole('dialog', { name: 'Journal' }); await preview.waitFor(); const photoChip = preview.getByRole('button', { name: 'Open Photo · 16:33' }); await photoChip.waitFor(); @@ -489,7 +489,7 @@ async function runLogAttachmentProof(page, server, today, timezoneId, calendarSh const bulkBlock = page.locator('.block.actual', { hasText: 'Thousand attachment import' }); await bulkBlock.waitFor(); await bulkBlock.click(); - const bulkPreview = page.getByRole('dialog', { name: 'Log entry' }); + const bulkPreview = page.getByRole('dialog', { name: 'Journal' }); await bulkPreview.waitFor(); assert.equal(await bulkPreview.locator('.attachment-row').count(), 32, 'the preview mounts one page, not all 1000 child cards'); await bulkPreview.getByText('1–32 of 1000', { exact: true }).waitFor(); diff --git a/apps/web/e2e/composer.mjs b/apps/web/e2e/composer.mjs index 4b31da9ab..ba0006233 100644 --- a/apps/web/e2e/composer.mjs +++ b/apps/web/e2e/composer.mjs @@ -787,7 +787,7 @@ try { const livePhotoEntry = page.locator('.block.actual', { hasText: 'morning run' }); await livePhotoEntry.waitFor(); await livePhotoEntry.click(); - const livePhotoPreview = page.getByRole('dialog', { name: 'Log entry' }); + const livePhotoPreview = page.getByRole('dialog', { name: 'Journal' }); await livePhotoPreview.waitFor(); await livePhotoPreview.getByRole('button', { name: /Open (run\.png|Photo ·)/ }).waitFor(); await page.keyboard.press('Escape'); diff --git a/apps/web/e2e/motion-spring-evidence.mjs b/apps/web/e2e/motion-spring-evidence.mjs index 7c340dafd..9df609cf7 100644 --- a/apps/web/e2e/motion-spring-evidence.mjs +++ b/apps/web/e2e/motion-spring-evidence.mjs @@ -746,7 +746,7 @@ async function exerciseMotion(page, cdp, base, theme, profile) { await page.goto(`${base}/calendar/day/${localDate()}`); const calendarBlock = page.locator('.block.actual', { hasText: 'Motion spring evidence log' }); await calendarBlock.waitFor({ state: 'visible' }); - const calendarPreview = page.getByRole('dialog', { name: 'Log entry' }); + const calendarPreview = page.getByRole('dialog', { name: 'Journal' }); await captureTransition(page, profile, theme, 'calendar-preview-open', async () => { await calendarBlock.hover(); await calendarPreview.waitFor(); diff --git a/apps/web/e2e/pill-feedback.mjs b/apps/web/e2e/pill-feedback.mjs index 28729cb68..8e9a7090a 100644 --- a/apps/web/e2e/pill-feedback.mjs +++ b/apps/web/e2e/pill-feedback.mjs @@ -330,7 +330,7 @@ async function openLogPreview(page, base, date, title = LOG_TITLE) { await entry.focus(); await page.keyboard.press('Enter'); } - const preview = page.getByRole('article', { name: 'Log entry' }); + const preview = page.getByRole('article', { name: 'Journal' }); await preview.waitFor({ state: 'visible' }); // Leaving the Calendar anchor changes its hover box; settle the 180 ms popover move before reading pill bounds. await page.mouse.move(1, 1); diff --git a/apps/web/src/lib/calendar/ItemPreview.svelte.test.ts b/apps/web/src/lib/calendar/ItemPreview.svelte.test.ts index cbe7da70d..7d99504fa 100644 --- a/apps/web/src/lib/calendar/ItemPreview.svelte.test.ts +++ b/apps/web/src/lib/calendar/ItemPreview.svelte.test.ts @@ -4,7 +4,7 @@ */ import { fireEvent, render, screen } from '@testing-library/svelte'; import { describe, expect, it, vi } from 'vitest'; -import { ItemPreview } from '@calternal/ui'; +import { ItemPreview, calendarItemActions } from '@calternal/ui'; import type { PreviewItem } from '@calternal/ui'; describe('Calendar item preview labels', () => { @@ -26,7 +26,7 @@ describe('Calendar item preview labels', () => { expect(screen.getByRole('article').getAttribute('aria-label')).toBe('Journal'); expect(screen.getByText('Journal')).toBeTruthy(); - expect(screen.getByRole('group', { name: 'Journal actions' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Copy link to this Journal entry' })).toBeTruthy(); }); it.each([ @@ -49,10 +49,11 @@ describe('Calendar item preview labels', () => { const { container } = render(ItemPreview, { item }); const heading = screen.getByRole('heading'); const titleChip = heading.querySelector('.pill.tag'); - expect(titleChip?.textContent).toBe('#area/proof'); + expect(titleChip?.textContent).toBe('proof'); + expect(titleChip?.getAttribute('aria-label')).toBe('area/proof'); expect([...heading.childNodes].some((node) => node.nodeType === Node.TEXT_NODE && node.textContent?.includes('#area/proof'))).toBe(false); expect(container.querySelectorAll('.tags .pill.tag')).toHaveLength(1); - expect(container.querySelector('.tags')?.textContent).toContain('#work'); + expect(container.querySelector('.tags')?.textContent).toContain('work'); }); it('normalizes Unicode title Tags before matching their metadata chips', () => { @@ -63,10 +64,64 @@ describe('Calendar item preview labels', () => { }; const { container } = render(ItemPreview, { item }); - expect([...container.querySelectorAll('.title .pill.tag')].map((chip) => chip.textContent)).toEqual(['#é', '#作業']); + expect([...container.querySelectorAll('.title .pill.tag')].map((chip) => chip.textContent)).toEqual(['é', '作業']); + expect([...container.querySelectorAll('.title .pill.tag')].map((chip) => chip.getAttribute('title'))).toEqual(['é', '作業']); expect(container.querySelector('.tags')).toBeNull(); }); + it('renders the Journal date, time chips, leaf Tags and two ordered action groups', async () => { + const item: PreviewItem = { + kind: 'log', + date: '2026-09-29', + log: { id: 'log-card', start: '15:20', end: '16:18', title: 'Self care', tags: ['area/selfcare', 'area/health'], attachments: [] } + }; + const callbacks = { + onattachment: vi.fn(), + onrecordvoice: vi.fn(), + onedit: vi.fn(), + onhide: vi.fn(), + onduplicate: vi.fn(), + ondelete: vi.fn() + }; + const { container } = render(ItemPreview, { item, ...callbacks }); + + expect(screen.getByRole('article', { name: 'Journal' })).toBeTruthy(); + expect(container.querySelector('.date-time')?.textContent).toContain('2026'); + expect([...container.querySelectorAll('.date-time .time-chip')].map((chip) => chip.textContent?.trim())).toEqual(['3:20 PM', '4:18 PM']); + const tag = container.querySelector('.tags .pill.tag'); + expect(tag?.textContent).toBe('selfcare'); + expect(tag?.getAttribute('title')).toBe('area/selfcare'); + expect(screen.getByRole('button', { name: 'Copy link to this Journal entry' })).toBeTruthy(); + expect([...container.querySelectorAll('.actions > [role="group"]')].map((group) => group.getAttribute('aria-label'))).toEqual([ + 'Journal attachment actions', + 'Journal actions' + ]); + expect([...container.querySelectorAll('.actions [data-calendar-action]')].map((button) => button.getAttribute('data-calendar-action'))).toEqual([ + 'attach-file', 'voice-memo', 'edit', 'hide', 'duplicate', 'delete' + ]); + await fireEvent.click(screen.getByRole('button', { name: 'Hide' })); + expect(callbacks.onhide).toHaveBeenCalledOnce(); + }); + + it('provides one ordered action definition for the card and right-click menu', () => { + const item: PreviewItem = { + kind: 'log', + date: '2026-09-29', + log: { id: 'log-actions', start: '08:30', end: null, title: 'Review', tags: [], attachments: [] } + }; + expect(calendarItemActions(item, { + 'copy-link': true, + 'attach-file': true, + 'voice-memo': true, + edit: true, + hide: true, + duplicate: true, + delete: true + }).map((action) => action.id)).toEqual([ + 'copy-link', 'attach-file', 'voice-memo', 'edit', 'hide', 'duplicate', 'delete' + ]); + }); + it.each([ { kind: 'log', diff --git a/apps/web/src/lib/webmcp/tools.ts b/apps/web/src/lib/webmcp/tools.ts index 6866f7955..246b7a6c2 100644 --- a/apps/web/src/lib/webmcp/tools.ts +++ b/apps/web/src/lib/webmcp/tools.ts @@ -81,7 +81,7 @@ function calendarEnum<Value extends string>(value: string | undefined, values: r return value as Value; } -export type ConfirmWrite = (action: 'Change data' | 'mail_read_state' | 'mail_read_marking' | 'mail_category' | 'mail_remote_content' | 'mail_save_attachment' | 'Create a Journal entry' | 'Create a Log entry' | 'Create a Task' | 'Create a Note' | 'Complete the Task' | 'Reopen the Task' | 'Duplicate an item' | 'Add attachments to a Journal entry' | 'Create a Calendar feed' | 'Update a Calendar feed' | 'Rotate a Calendar feed link' | 'Revoke a Calendar feed link' | 'Delete a Calendar feed' | 'Add an external Calendar' | 'Update an external Calendar' | 'Refresh an external Calendar' | 'Remove an external Calendar', summary: string) => Promise<boolean>; +export type ConfirmWrite = (action: 'Change data' | 'mail_read_state' | 'mail_read_marking' | 'mail_category' | 'mail_remote_content' | 'mail_save_attachment' | 'Create a Journal entry' | 'Create a Task' | 'Create a Note' | 'Complete the Task' | 'Reopen the Task' | 'Duplicate an item' | 'Add attachments to a Journal entry' | 'Create a Calendar feed' | 'Update a Calendar feed' | 'Rotate a Calendar feed link' | 'Revoke a Calendar feed link' | 'Delete a Calendar feed' | 'Add an external Calendar' | 'Update an external Calendar' | 'Refresh an external Calendar' | 'Remove an external Calendar', summary: string) => Promise<boolean>; function fields(input: unknown, allowed: readonly string[]): Record<string, unknown> { if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Invalid tool input.'); diff --git a/apps/web/src/routes/calendar/[view]/[date]/+page.svelte b/apps/web/src/routes/calendar/[view]/[date]/+page.svelte index e364c55b5..3584c7ec3 100644 --- a/apps/web/src/routes/calendar/[view]/[date]/+page.svelte +++ b/apps/web/src/routes/calendar/[view]/[date]/+page.svelte @@ -54,6 +54,7 @@ TimeGrid, YearHeatmap, activityCount, + calendarItemActions, addDays, addMonths, fromMinutes, @@ -143,8 +144,15 @@ import { calendarSidebar } from '$lib/calendar/sidebarState.svelte'; import { usePageChrome } from '$lib/chrome.svelte'; import PlusIcon from '@lucide/svelte/icons/plus'; + import CalendarIcon from '@lucide/svelte/icons/calendar'; import InfoIcon from '@lucide/svelte/icons/info'; import LinkIcon from '@lucide/svelte/icons/link'; + import PaperclipIcon from '@lucide/svelte/icons/paperclip'; + import MicIcon from '@lucide/svelte/icons/mic'; + import PencilIcon from '@lucide/svelte/icons/pencil'; + import EyeOffIcon from '@lucide/svelte/icons/eye-off'; + import CopyIcon from '@lucide/svelte/icons/copy'; + import TrashIcon from '@lucide/svelte/icons/trash-2'; import { toast, UNDO_MS } from '$lib/stores/toasts.svelte'; import { composer, openComposer } from '$lib/composer/controller.svelte'; import { composerVoiceRecorder } from '$lib/composer/recorder.svelte'; @@ -828,8 +836,8 @@ }); } - /** Remove a stale file link through Notes so the Markdown and Calendar update together (#427). */ - function removeMissingAttachment(date: string, log: CalendarLog, attachment: CalendarAttachment): void { + /** Remove one attachment link through Notes so the Daily note and Calendar update together (#427, #628). */ + function removeJournalAttachment(date: string, log: CalendarLog, attachment: CalendarAttachment): void { if (!log.id) return; closePreview(); void removeLogAttachment(date, log.id, attachment.target).then(() => { @@ -878,7 +886,7 @@ const id = log.id!; return { ondelete: () => deleteLog(day, log), - onremoveattachment: (attachment: CalendarAttachment) => removeMissingAttachment(day, log, attachment), + onremoveattachment: (attachment: CalendarAttachment) => removeJournalAttachment(day, log, attachment), onnote: (title: string, body: string) => logToNote(day, log, title, body), noteId: noteIds.get(id) ?? null }; @@ -1202,7 +1210,7 @@ /** Build the stable deep link for each Calendar item kind (DESIGN §33). */ function contextHref(item: PreviewItem): string | null { if (item.kind === 'log') return logEntryHref(item.date, item.log.id); - if (item.kind === 'event') return eventHref(item.event.id); + if (item.kind === 'event') return eventHref(item.event.id, item.date, item.event.sourceId); if (item.kind === 'task') return taskHref(item.task.id); return null; } @@ -1233,17 +1241,22 @@ openItemContext(target, x, y); } - /** Dispatch shared item-menu actions without duplicating their write paths (#421; DESIGN §41). */ + /** Dispatch the card and context menu through the same Calendar item action IDs (#581, #628). */ function runItemContext(action: string): void { const context = itemContext; if (!context) return; - if (action === 'preview') { - selected = { item: context.item, anchor: context.anchor, key: context.key }; - openPreview(context.item, context.anchor, context.key); - } else if (action === 'edit') editItem(context.item, context.anchor, context.key); + if (action === 'edit') editItem(context.item, context.anchor, context.key); else if (action === 'duplicate') void duplicateItem(context.item); - else if (action === 'day') openDay(context.item.date); - else if (action === 'copy') { + else if (action === 'hide') { + closePreview(); + closeItemContext(); + } else if (action === 'attach-file' && context.item.kind === 'log') { + chooseLogAttachments(context.item, context.anchor, context.key); + } else if (action === 'voice-memo' && context.item.kind === 'log') { + recordLogVoice(context.item, context.anchor, context.key); + } else if (action === 'delete' && context.item.kind === 'log') deleteLog(context.item.date, context.item.log); + else if (action === 'copy-to-calendar' && context.item.kind === 'event') void copySubscribedEvent(context.item.event, context.item.date); + else if (action === 'copy-link') { const href = contextHref(context.item); if (href) void copyLinkWithToast(href); } @@ -1256,18 +1269,44 @@ if (anchor?.isConnected && !anchor.contains(document.activeElement)) anchor.focus({ preventScroll: true }); } - /** Build the same accessible actions for Agenda, grid, Month and touch (#421). */ + /** Build the shared action list for Calendar cards and right-click menus (#581, #628). */ function itemContextItems(item: PreviewItem): MenuNode[] { - const canDuplicate = item.kind !== 'log' || !!item.log.id; - const label = item.kind === 'log' ? 'Journal entry' : item.kind === 'event' ? 'Event' : 'Task'; - return [ - { id: 'edit', label: `Edit ${label}` }, - { id: 'preview', label: 'Show preview', kbd: ['cmd', 'i'] }, - ...(canDuplicate ? [{ id: 'duplicate', label: 'Duplicate', shortcut: 'calendar.duplicate' as const }] : []), - { id: 'day', label: 'Open day' }, - { type: 'separator' }, - { id: 'copy', label: item.kind === 'log' ? 'Copy link to this Journal entry' : item.kind === 'event' ? 'Copy link to this Event' : 'Copy link to this Task' } - ]; + if (item.kind !== 'log' && item.kind !== 'event' && item.kind !== 'task') return []; + const stable = item.kind === 'log' ? !!item.log.id : true; + const actions = calendarItemActions(item, { + 'copy-link': stable, + 'attach-file': item.kind === 'log' && stable, + 'voice-memo': item.kind === 'log' && stable, + edit: stable && !(item.kind === 'event' && item.event.readOnly), + hide: true, + duplicate: stable, + 'copy-to-calendar': item.kind === 'event' && item.event.readOnly, + delete: item.kind === 'log' && stable + }); + const icons = { + 'copy-link': LinkIcon, + 'attach-file': PaperclipIcon, + 'voice-memo': MicIcon, + edit: PencilIcon, + hide: EyeOffIcon, + duplicate: CopyIcon, + 'copy-to-calendar': CalendarIcon, + delete: TrashIcon + }; + const nodes: MenuNode[] = []; + let previousGroup = ''; + for (const action of actions) { + if (previousGroup && previousGroup !== action.group) nodes.push({ type: 'separator' }); + nodes.push({ + id: action.id, + label: action.label, + icon: icons[action.id], + shortcut: action.shortcut, + danger: action.destructive + }); + previousGroup = action.group; + } + return nodes; } /** Turn a second touch on the same item into edit before its click selects again (#421; DESIGN §34). */ @@ -2047,7 +2086,7 @@ oncopyevent={copySubscribedEvent} oneditstart={() => primeKeyboard({ inputType: 'text', inputMode: 'text' })} ontag={(tag) => goto(tagHref(tag))} - onopenday={view === 'day' ? undefined : openDay} + onhide={closePreview} onnavigate={(href) => void goto(href)} onclose={closePreview} {...logActions(currentPreview.item)} diff --git a/packages/ui/src/components/TagPill.svelte b/packages/ui/src/components/TagPill.svelte index 2a5855b44..51e491e80 100644 --- a/packages/ui/src/components/TagPill.svelte +++ b/packages/ui/src/components/TagPill.svelte @@ -2,8 +2,10 @@ <script lang="ts"> // TagPill keeps its per-tag colour and uses the shared press action only when // it is interactive (DESIGN §34, issue #425). Static chips stay decorative. + // Calendar cards can show a Tag's leaf while its full namespace stays in the + // tooltip and accessible name (#628); other surfaces keep the full `#tag`. import { pillFeedback } from '$lib/actions/pillFeedback'; - import { tagColor } from '../tags'; + import { tagColor, tagLeaf } from '../tags'; interface Props { tag: string; @@ -33,10 +35,13 @@ size?: 'sm' | 'md'; /** User-selected tag colors supplied by the owning application. */ colorOverrides?: Record<string, string>; + /** Show only the final path segment, while retaining the full Tag for AT. */ + leafOnly?: boolean; } - let { tag, onclick, active = false, haspopup, expanded = false, size = 'sm', colorOverrides = {} }: Props = $props(); + let { tag, onclick, active = false, haspopup, expanded = false, size = 'sm', colorOverrides = {}, leafOnly = false }: Props = $props(); let c = $derived(tagColor(tag, colorOverrides)); + let text = $derived(leafOnly ? tagLeaf(tag) : `#${tag}`); </script> {#if onclick} @@ -47,12 +52,14 @@ style="--tfg:{c.fg}; --tbg:{c.bg}" use:pillFeedback onclick={() => onclick?.(tag)} + title={leafOnly ? tag : undefined} + aria-label={leafOnly ? tag : undefined} aria-haspopup={haspopup} aria-expanded={haspopup ? expanded : undefined} aria-pressed={haspopup ? undefined : active} - >#{tag}</button> + >{text}</button> {:else} - <span class="pill tag" class:md={size === 'md'} style="--tfg:{c.fg}; --tbg:{c.bg}">#{tag}</span> + <span class="pill tag" class:md={size === 'md'} style="--tfg:{c.fg}; --tbg:{c.bg}" title={leafOnly ? tag : undefined} aria-label={leafOnly ? tag : undefined}>{text}</span> {/if} <style> diff --git a/packages/ui/src/components/calendar/AgendaList.svelte b/packages/ui/src/components/calendar/AgendaList.svelte index 22d4e2a84..fa8aed8b3 100644 --- a/packages/ui/src/components/calendar/AgendaList.svelte +++ b/packages/ui/src/components/calendar/AgendaList.svelte @@ -538,7 +538,7 @@ class:pending={log.pending} data-key={key} tabindex={isTabStop(key, day.date) ? 0 : -1} - aria-label={log.pending ? `Sending Log entry at ${formatClockTime(part.start)}: ${log.title}` : undefined} + aria-label={log.pending ? `Sending Journal entry at ${formatClockTime(part.start)}: ${log.title}` : undefined} aria-disabled={log.pending || undefined} aria-pressed={countId ? undefined : selected === key} aria-expanded={countId ? selected === key : undefined} diff --git a/packages/ui/src/components/calendar/AttachmentDeck.svelte b/packages/ui/src/components/calendar/AttachmentDeck.svelte index e136e4f7c..ab0e23af7 100644 --- a/packages/ui/src/components/calendar/AttachmentDeck.svelte +++ b/packages/ui/src/components/calendar/AttachmentDeck.svelte @@ -21,6 +21,10 @@ // every card absolutely at its deck position with the title transparent // and the thumbnail posed (rotated, scaled 28/32); the open layout is the // normal wrapping flow. + // - Calendar ItemPreview opts into `layout="rows"` (#628): one row per + // attachment shows its kind label, keeps the full name in the tooltip and + // reveals Copy link/Delete in a trailing PillGroup on hover or focus. The + // Agenda keeps the original card layout and single attachment identity. // // THE MORPH is a translate-only FLIP on `.row-content` ($lib/flip, ported // with this component). Toggle/close capture every First rect before their @@ -67,13 +71,16 @@ import FileArchiveIcon from '@lucide/svelte/icons/file-archive'; import SquareCheckIcon from '@lucide/svelte/icons/square-check'; import LinkIcon from '@lucide/svelte/icons/link'; + import TrashIcon from '@lucide/svelte/icons/trash-2'; import { captureRects, flipGroup, type RectLike } from '$lib/flip'; import { prefersReducedMotion } from '$lib/reducedMotion.svelte'; import { tip } from '../tooltip/tip'; import FileThumb from '../files/FileThumb.svelte'; import { fileGlyphKind, officeFileLabel } from '../files/FileIcon.svelte'; - import { attachmentRefs, attachmentCountLabel, attachmentFileName, type AttachmentRef } from './attachments'; + import { attachmentRefs, attachmentCountLabel, attachmentFileName, attachmentKindLabel, attachmentShareHref, type AttachmentRef } from './attachments'; import type { CalendarAttachment } from './model'; + import CopyLink from '../CopyLink.svelte'; + import PillGroup from '../PillGroup.svelte'; interface Props { attachments: readonly CalendarAttachment[]; @@ -82,17 +89,19 @@ ontoggle: (next: boolean) => void; /** Open one attachment (note, file, task or link). */ onopen: (attachment: CalendarAttachment) => void; - /** Remove a Missing link from its Log entry. */ + /** Remove one attachment link from its Journal entry, live or Missing. */ onremove?: (attachment: CalendarAttachment) => void; /** Where focus goes when the deck closes from inside (the entry line). */ returnFocus?: () => void; /** Number of cards shown in the collapsed stack. */ maxCollapsed?: number; + /** Render one labeled row per attachment in the Calendar hover preview. */ + layout?: 'cards' | 'rows'; /** Id of the visually hidden count label, for the entry's aria-describedby. */ countId?: string; } - let { attachments, expanded, ontoggle, onopen, onremove, returnFocus, maxCollapsed = 3, countId }: Props = $props(); + let { attachments, expanded, ontoggle, onopen, onremove, returnFocus, maxCollapsed = 3, countId, layout = 'cards' }: Props = $props(); const refs = $derived(attachmentRefs(attachments)); const PAGE_SIZE = 32; @@ -556,6 +565,7 @@ bind:this={listEl} id={listId} class="attachment-list" + class:rows={layout === 'rows' && expanded} class:is-stack={!expanded} class:is-fan={fanHovered} style={`--last:${shown.length - 1}`} @@ -567,24 +577,63 @@ > {#each pageRefs as ref, i (ref.key)} <li style={`--i:${i}`} data-overflow={!expanded && i >= shown.length ? '' : undefined}> - <button - type="button" - class="attachment-row" - data-tooltip-obstacle - aria-label={ref.label} - class:missing={ref.missing} - {...tip(ref.fullName)} - onclick={(event) => { - event.stopPropagation(); - openRef(ref); - }} - > - <span class="row-content" class:glass-overlay={fanHovered && !expanded}> - {@render thumb(ref)} - <span class="attachment-name" dir="auto">{ref.name}</span> - {#if ref.missing}<span class="missing-action">Missing · Remove link</span>{/if} - </span> - </button> + {#if layout === 'rows' && expanded} + {@const kindLabel = attachmentKindLabel(ref.attachment)} + {@const shareHref = attachmentShareHref(ref.attachment)} + <div class="attachment-line" class:missing={ref.missing}> + <button + type="button" + class="attachment-row" + data-tooltip-obstacle + aria-label={ref.label} + {...tip(ref.fullName)} + onclick={(event) => { + event.stopPropagation(); + openRef(ref); + }} + > + <span class="row-content"> + <span class="thumb tile kind-{ref.kind}">{@render glyph(ref)}</span> + <span class="attachment-name" dir="auto">{kindLabel}</span> + {#if ref.missing}<span class="missing-action">Missing</span>{/if} + </span> + </button> + <PillGroup label="Attachment actions for {ref.fullName}" buttonSize="compact" class="attachment-actions"> + {#if shareHref}<CopyLink href={shareHref} label="Copy link to {ref.fullName}" />{/if} + {#if onremove} + <button + type="button" + class="pg-btn danger" + aria-label="Delete attachment {ref.fullName}" + {...tip('Delete')} + onclick={(event) => { + event.stopPropagation(); + onremove?.(ref.attachment); + }} + ><TrashIcon aria-hidden="true" /><span>Delete</span></button> + {/if} + </PillGroup> + </div> + {:else} + <button + type="button" + class="attachment-row" + data-tooltip-obstacle + aria-label={ref.label} + class:missing={ref.missing} + {...tip(ref.fullName)} + onclick={(event) => { + event.stopPropagation(); + openRef(ref); + }} + > + <span class="row-content" class:glass-overlay={fanHovered && !expanded}> + {@render thumb(ref)} + <span class="attachment-name" dir="auto">{ref.name}</span> + {#if ref.missing}<span class="missing-action">Missing · Remove link</span>{/if} + </span> + </button> + {/if} </li> {/each} </ul> @@ -693,6 +742,59 @@ min-width: 0; max-width: min(15rem, 100%); } + .attachment-list.rows { + flex-direction: column; + flex-wrap: nowrap; + gap: 2px; + padding-block: 0; + } + .attachment-list.rows > li { + width: 100%; + max-width: none; + } + .attachment-line { + display: flex; + align-items: center; + gap: 4px; + width: 100%; + min-width: 0; + } + .attachment-list.rows .attachment-row { + flex: 1; + min-width: 0; + } + .attachment-list.rows .row-content { + width: 100%; + border-color: transparent; + background: transparent; + box-shadow: none; + padding-right: 4px; + } + .attachment-line.missing .attachment-name, + .attachment-line.missing .missing-action { + color: var(--muted); + } + :global(.attachment-actions) { + flex: none; + opacity: 0; + pointer-events: none; + transition: opacity var(--dur-press) var(--ease-standard-out); + } + :global(.attachment-actions .pg-btn) { + gap: 4px; + padding-inline: 8px; + } + :global(.attachment-actions .pg-btn span) { + font-size: var(--text-caption); + } + .attachment-line:hover :global(.attachment-actions), + .attachment-line:focus-within :global(.attachment-actions) { + opacity: 1; + pointer-events: auto; + } + @media (hover: none) { + :global(.attachment-actions) { opacity: 1; pointer-events: auto; } + } .attachment-row { box-sizing: border-box; display: flex; diff --git a/packages/ui/src/components/calendar/GridColumn.svelte b/packages/ui/src/components/calendar/GridColumn.svelte index b611e767b..db757fda0 100644 --- a/packages/ui/src/components/calendar/GridColumn.svelte +++ b/packages/ui/src/components/calendar/GridColumn.svelte @@ -19,7 +19,8 @@ extends below the day (#469). Level of detail is opacity only (never display or size), so tier changes do not move content inside a block: - tier 1: the title (always); - - tier 2: time and tags; + - tier 2: a clock and separate start/end time chips; + - tier 3: leaf Tag chips; - tier 3: the attachment deck under the text. Below tier 3, and in a block too short for the deck, the attachments show as a small pile at the end of the title line (the "line" form); the two cross-fade. @@ -49,6 +50,7 @@ --> <script lang="ts"> import CalendarSyncIcon from '@lucide/svelte/icons/calendar-sync'; + import Clock3Icon from '@lucide/svelte/icons/clock-3'; import FileIcon from '@lucide/svelte/icons/file'; import FileTextIcon from '@lucide/svelte/icons/file-text'; import BookmarkIcon from '@lucide/svelte/icons/bookmark'; @@ -60,7 +62,7 @@ import { layoutPlanActual, itemSlots, type ItemSlot, type Placed } from './layout'; import { ariaKeyshortcuts } from '$lib/shortcuts/format'; import { shortcutCombos } from '$lib/shortcuts/registry'; - import { eventTint, tagColor, tagLeaf } from '../../tags'; + import { eventTint, splitInlineTags, tagColor, tagLeaf } from '../../tags'; import { addDays, formatClockTime, formatLongDate } from '../../time'; import { attachmentRefs } from './attachments'; import { LOD2_HOUR, LOD3_HOUR, LOD_HYSTERESIS, MIN_HOUR_HEIGHT, minimumGridDurationMinutes } from './zoom'; @@ -350,16 +352,17 @@ import { LOD2_HOUR, LOD3_HOUR, LOD_HYSTERESIS, MIN_HOUR_HEIGHT, minimumGridDurat /** * How many title lines fit in a block of `minutes` at the smallest zoom of - * each tier (24, 44 and 84 px an hour), after the time line (tiers 2, 3) - * and the deck (tier 3). The title is clamped to whole lines, so a long + * each tier (24, 44 and 84 px an hour), after the time and Tag rows and the + * attachment deck. The title is clamped to whole lines, so a long * title ends in an ellipsis, never in a line cut through its middle. The * tier classes only select these precomputed line counts; short-entry * occupancy is recalculated when the saved zoom rests (#384). */ - function titleLines(minutes: number, deck: boolean): [number, number, number] { - const [meta2, meta3] = metaFits(minutes); + function titleLines(minutes: number, hasTags: boolean, deck: boolean): [number, number, number] { + const [meta2, meta3] = metaFits(minutes, hasTags); const fit = (hour: number, reserved: number) => Math.max(1, Math.floor(((minutes / 60) * hour - PAD - reserved) / LINE)); - return [fit(TIER_FLOOR[0], 0), fit(TIER_FLOOR[1], meta2 ? LINE : 0), fit(TIER_FLOOR[2], (meta3 ? LINE : 0) + (deck ? 31 : 0))]; + const detailLines = meta3 ? (hasTags ? 2 : 1) : meta2 ? 1 : 0; + return [fit(TIER_FLOOR[0], 0), fit(TIER_FLOOR[1], meta2 ? LINE : 0), fit(TIER_FLOOR[2], detailLines * LINE + (deck ? 31 : 0))]; } const LINE = 15; @@ -372,9 +375,9 @@ import { LOD2_HOUR, LOD3_HOUR, LOD_HYSTERESIS, MIN_HOUR_HEIGHT, minimumGridDurat * time is never cut by the block's edge (#134); the popover and the * accessible name still carry it. */ - function metaFits(minutes: number): [boolean, boolean] { - const fits = (hour: number) => (minutes / 60) * hour - PAD >= 2 * LINE; - return [fits(TIER_FLOOR[1]), fits(TIER_FLOOR[2])]; + function metaFits(minutes: number, hasTags: boolean): [boolean, boolean] { + const space = (hour: number) => (minutes / 60) * hour - PAD; + return [space(TIER_FLOOR[1]) >= 2 * LINE, space(TIER_FLOOR[2]) >= (hasTags ? 3 : 2) * LINE]; } /** @@ -393,6 +396,17 @@ import { LOD2_HOUR, LOD3_HOUR, LOD_HYSTERESIS, MIN_HOUR_HEIGHT, minimumGridDurat function minuteLabel(minutes: number): string { return formatClockTime(fromMinutes(minutes)); } + + /** Keep one Tag visible and collapse the remaining Tags to a count chip (#628). */ + function tagChipSummary(tags: readonly string[]): { leaf: string; rest: number; tooltip: string } | null { + const first = tags[0]; + return first ? { leaf: tagLeaf(first), rest: tags.length - 1, tooltip: tags.join(', ') } : null; + } + + /** Keep inline Tags on the block's Tag line so a narrow title keeps its room (#628). */ + function blockTitle(title: string): string { + return splitInlineTags(title).filter((part) => part.kind === 'text').map((part) => part.text).join('').trim(); + } </script> {#snippet rangeLabels(start: number, end: number, endClock?: string)} @@ -425,6 +439,7 @@ import { LOD2_HOUR, LOD3_HOUR, LOD_HYSTERESIS, MIN_HOUR_HEIGHT, minimumGridDurat {@const plan = placed.item} {@const key = `event:${date}:${ev.id}`} {@const tint = eventTint(ev.tags, ev.color)} + {@const tags = tagChipSummary(ev.tags)} <button type="button" class="block plan" @@ -435,8 +450,8 @@ import { LOD2_HOUR, LOD3_HOUR, LOD_HYSTERESIS, MIN_HOUR_HEIGHT, minimumGridDurat class:continues-before={plan.continuesBefore} class:continues-after={plan.continuesAfter} data-hit-exempt="time-grid" - class:meta2={metaFits(placed.end - placed.start)[0]} - class:meta3={metaFits(placed.end - placed.start)[1]} + class:meta2={metaFits(placed.end - placed.start, ev.tags.length > 0)[0]} + class:meta3={metaFits(placed.end - placed.start, ev.tags.length > 0)[1]} class:compact={colw / Math.min(placed.columns, maxLanes) < 136} class:cramped={cramped(placed.columns)} class:short={placed.end - placed.start <= 15} @@ -455,9 +470,9 @@ import { LOD2_HOUR, LOD3_HOUR, LOD_HYSTERESIS, MIN_HOUR_HEIGHT, minimumGridDurat style:--event-past-text={tint.pastText} style:--event-color={tint.color} title={ev.title} - style:--l1={titleLines(placed.layoutEnd - placed.layoutStart, false)[0]} - style:--l2={titleLines(placed.layoutEnd - placed.layoutStart, false)[1]} - style:--l3={titleLines(placed.layoutEnd - placed.layoutStart, false)[2]} + style:--l1={titleLines(placed.layoutEnd - placed.layoutStart, ev.tags.length > 0, false)[0]} + style:--l2={titleLines(placed.layoutEnd - placed.layoutStart, ev.tags.length > 0, false)[1]} + style:--l3={titleLines(placed.layoutEnd - placed.layoutStart, ev.tags.length > 0, false)[2]} aria-label={`Event from ${ev.provider}, ${formatClockTime(plan.start)}${plan.end ? ` to ${formatClockTime(plan.end)}` : ''}: ${ev.title}`} aria-keyshortcuts={canMovePlan(plan) ? 'ArrowUp ArrowDown' : undefined} onclick={(e) => { @@ -474,9 +489,10 @@ import { LOD2_HOUR, LOD3_HOUR, LOD_HYSTERESIS, MIN_HOUR_HEIGHT, minimumGridDurat }} onpointerleave={(event) => event.pointerType === 'mouse' && onhover?.(null, null, key)} > - <span class="b-content"> - <span class="b-title" dir="auto"><CalendarSyncIcon class="ext-icon" aria-hidden="true" />{ev.title}</span> - <span class="b-meta">{formatClockTime(plan.start)}{#if plan.end}&thinsp;–&thinsp;{formatClockTime(plan.end)}{/if}</span> + <span class="b-content" aria-hidden="true"> + <span class="b-time-row"><Clock3Icon aria-hidden="true" /><span class="b-time-chip">{formatClockTime(plan.start)}</span>{#if plan.end}<span class="b-time-chip">{formatClockTime(plan.end)}</span>{/if}</span> + <span class="b-title" dir="auto"><CalendarSyncIcon class="ext-icon" aria-hidden="true" />{blockTitle(ev.title)}</span> + {#if tags}<span class="b-tags" title={tags.tooltip}><span class="b-tag">{tags.leaf}</span>{#if tags.rest > 0}<span class="b-tag more">+{tags.rest}</span>{/if}</span>{/if} </span> </button> {/each} @@ -486,6 +502,7 @@ import { LOD2_HOUR, LOD3_HOUR, LOD_HYSTERESIS, MIN_HOUR_HEIGHT, minimumGridDurat {@const plan = placed.item} {@const key = `task:${task.id}`} {@const done = task.status === 'done' || task.status === 'cancelled'} + {@const tags = tagChipSummary(task.tags)} <button type="button" class="block plan task-plan" @@ -495,8 +512,8 @@ import { LOD2_HOUR, LOD3_HOUR, LOD_HYSTERESIS, MIN_HOUR_HEIGHT, minimumGridDurat class:continues-after={plan.continuesAfter} class:has-more={laid.hiddenClusters.has(placed.cluster ?? -1)} class:short={placed.layoutEnd - placed.layoutStart <= 15} - class:meta2={metaFits(placed.layoutEnd - placed.layoutStart)[0]} - class:meta3={metaFits(placed.layoutEnd - placed.layoutStart)[1]} + class:meta2={metaFits(placed.layoutEnd - placed.layoutStart, task.tags.length > 0)[0]} + class:meta3={metaFits(placed.layoutEnd - placed.layoutStart, task.tags.length > 0)[1]} class:compact={colw / Math.min(placed.columns, maxLanes) < 136} class:cramped={cramped(placed.columns)} class:done @@ -508,9 +525,9 @@ import { LOD2_HOUR, LOD3_HOUR, LOD_HYSTERESIS, MIN_HOUR_HEIGHT, minimumGridDurat style:--col={placed.column} style:--cols={Math.min(placed.columns, maxLanes)} style:--c="var(--accent)" - style:--l1={titleLines(placed.layoutEnd - placed.layoutStart, false)[0]} - style:--l2={titleLines(placed.layoutEnd - placed.layoutStart, false)[1]} - style:--l3={titleLines(placed.layoutEnd - placed.layoutStart, false)[2]} + style:--l1={titleLines(placed.layoutEnd - placed.layoutStart, task.tags.length > 0, false)[0]} + style:--l2={titleLines(placed.layoutEnd - placed.layoutStart, task.tags.length > 0, false)[1]} + style:--l3={titleLines(placed.layoutEnd - placed.layoutStart, task.tags.length > 0, false)[2]} title={task.title} aria-label={`Task ${formatClockTime(plan.start)}${plan.end ? ` to ${formatClockTime(plan.end)}` : ''}: ${task.title}${done ? ', done' : ''}${plan.continuesBefore ? ', continues from previous day' : plan.continuesAfter ? ', continues next day' : ''}`} aria-keyshortcuts={canMovePlan(plan) ? 'ArrowUp ArrowDown' : undefined} @@ -528,9 +545,10 @@ import { LOD2_HOUR, LOD3_HOUR, LOD_HYSTERESIS, MIN_HOUR_HEIGHT, minimumGridDurat }} onpointerleave={(event) => event.pointerType === 'mouse' && onhover?.(null, null, key)} > - <span class="b-content"> - <span class="b-title" dir="auto">{#if done}<SquareCheckIcon aria-hidden="true" />{:else}<SquareIcon aria-hidden="true" />{/if}{task.title}</span> - <span class="b-meta">{formatClockTime(plan.start)}{#if plan.end}&thinsp;–&thinsp;{formatClockTime(plan.end)}{/if}</span> + <span class="b-content" aria-hidden="true"> + <span class="b-time-row"><Clock3Icon aria-hidden="true" /><span class="b-time-chip">{formatClockTime(plan.start)}</span>{#if plan.end}<span class="b-time-chip">{formatClockTime(plan.end)}</span>{/if}</span> + <span class="b-title" dir="auto">{#if done}<SquareCheckIcon aria-hidden="true" />{:else}<SquareIcon aria-hidden="true" />{/if}{blockTitle(task.title)}</span> + {#if tags}<span class="b-tags" title={tags.tooltip}><span class="b-tag">{tags.leaf}</span>{#if tags.rest > 0}<span class="b-tag more">+{tags.rest}</span>{/if}</span>{/if} </span> </button> {/each} @@ -541,8 +559,9 @@ import { LOD2_HOUR, LOD3_HOUR, LOD_HYSTERESIS, MIN_HOUR_HEIGHT, minimumGridDurat {@const key = logKey(part)} {@const refs = log.attachments.length ? attachmentRefs(log.attachments) : []} {@const deckable = placed.end - placed.start >= 54} - {@const lines = titleLines(placed.layoutEnd - placed.layoutStart, refs.length > 0 && deckable)} - {@const meta = metaFits(placed.end - placed.start)} + {@const tags = tagChipSummary(log.tags)} + {@const lines = titleLines(placed.layoutEnd - placed.layoutStart, log.tags.length > 0, refs.length > 0 && deckable)} + {@const meta = metaFits(placed.end - placed.start, log.tags.length > 0)} <button type="button" class="block actual" @@ -588,10 +607,11 @@ import { LOD2_HOUR, LOD3_HOUR, LOD_HYSTERESIS, MIN_HOUR_HEIGHT, minimumGridDurat onpointerleave={(event) => event.pointerType === 'mouse' && onhover?.(null, null, key)} > <!-- Sticky inside the block: when the block's top scrolls out of view, - its title and time stay on the visible edge (Apple Calendar). --> + its clock and title stay on the visible edge (Apple Calendar). --> <span class="b-content" aria-hidden="true"> + <span class="b-time-row"><Clock3Icon aria-hidden="true" /><span class="b-time-chip">{formatClockTime(part.start)}</span>{#if part.end}<span class="b-time-chip">{formatClockTime(part.end)}</span>{/if}{#if log.place}<span class="b-place">{log.place.name}</span>{/if}</span> <span class="b-title-row"> - <span class="b-title" dir="auto">{log.title || 'Untitled entry'}</span> + <span class="b-title" dir="auto">{blockTitle(log.title || 'Untitled entry')}</span> {#if refs.length > 0} <!-- Line form: a compact pile at the title line, never over its text (#384, DESIGN §39). --> <span class="b-pile"> @@ -605,7 +625,7 @@ import { LOD2_HOUR, LOD3_HOUR, LOD_HYSTERESIS, MIN_HOUR_HEIGHT, minimumGridDurat </span> {/if} </span> - <span class="b-meta" title={log.tags.length ? log.tags.map((tag) => `#${tag}`).join(' ') : undefined}>{formatClockTime(part.start)}{#if part.end}&thinsp;–&thinsp;{formatClockTime(part.end)}{/if}{#if log.place}&ensp;·&ensp;{log.place.name}{/if}{#if log.tags.length > 0}&ensp;{log.tags.map((tag) => `#${tagLeaf(tag)}`).join(' ')}{/if}</span> + {#if tags}<span class="b-tags" title={tags.tooltip}><span class="b-tag">{tags.leaf}</span>{#if tags.rest > 0}<span class="b-tag more">+{tags.rest}</span>{/if}</span>{/if} {#if refs.length > 0} <!-- Card form (tier 3): the attachment deck under the text. --> <span class="b-deck"> @@ -1013,7 +1033,7 @@ import { LOD2_HOUR, LOD3_HOUR, LOD_HYSTERESIS, MIN_HOUR_HEIGHT, minimumGridDurat top: var(--calendar-event-padding-block); display: flex; flex-direction: column; - gap: 1px; + gap: 2px; min-width: 0; } .b-title-row { @@ -1038,6 +1058,78 @@ import { LOD2_HOUR, LOD3_HOUR, LOD_HYSTERESIS, MIN_HOUR_HEIGHT, minimumGridDurat .b-title :global(.ext-icon) { color: color-mix(in srgb, var(--ink) 55%, transparent); } + /* Time is one row of separate chips so a range stays scannable (#628). */ + .b-time-row { + display: flex; + flex: none; + align-items: center; + gap: 3px; + min-width: 0; + overflow: hidden; + color: color-mix(in srgb, var(--ink) 74%, transparent); + font-size: var(--text-caption); + font-variant-numeric: tabular-nums; + white-space: nowrap; + opacity: 0; + transition: opacity var(--dur-open) var(--ease-standard-out); + } + .block.plan .b-time-row { + color: var(--event-text); + } + .block.plan.past .b-time-row { + color: var(--event-past-text); + } + .b-time-row :global(svg) { + width: 12px; + height: 12px; + flex: none; + vertical-align: calc((1cap - 12px) / 2); + } + .b-time-chip { + min-width: 0; + padding: 0 4px; + border-radius: var(--radius-pill); + background: color-mix(in srgb, currentColor 8%, transparent); + line-height: var(--leading-tight); + } + .b-place { + overflow: hidden; + text-overflow: ellipsis; + } + /* Tags use one leaf chip and one overflow count. The full paths are titles. */ + .b-tags { + display: flex; + flex: none; + gap: 3px; + min-width: 0; + overflow: hidden; + font-size: var(--text-caption); + opacity: 0; + transition: opacity var(--dur-open) var(--ease-standard-out); + } + .b-tag { + min-width: 0; + overflow: hidden; + padding: 0 5px; + border-radius: var(--radius-pill); + background: color-mix(in srgb, var(--c) 16%, var(--surface)); + color: color-mix(in srgb, var(--ink) 80%, var(--c)); + text-overflow: ellipsis; + white-space: nowrap; + } + .b-tag.more { + flex: none; + font-variant-numeric: tabular-nums; + } + :global(:root.is-dark) .b-tag { + background: color-mix(in srgb, var(--c) 26%, var(--surface)); + color: var(--ink); + } + :global(.lod-2) .meta2 .b-time-row, + :global(.lod-3) .meta3 .b-time-row, + :global(.lod-3) .meta3 .b-tags { + opacity: 1; + } /* Titles wrap between words; a word longer than the line breaks as the last resort. The block clips what does not fit, so a 2,000-character title costs one text layout, not a taller block. */ @@ -1074,10 +1166,9 @@ import { LOD2_HOUR, LOD3_HOUR, LOD_HYSTERESIS, MIN_HOUR_HEIGHT, minimumGridDurat .block.cramped.has-files .b-title { padding-right: 0; } - /* A Week column has enough room to show a short Event title, but the source - glyph and time consume most of that width. Remove those decorations before - the title becomes cramped; the native title and accessible name keep the - complete Event title available. */ + /* A Week column has enough room to show a short Event title, but its clock + and time chips consume most of that width. Keep the title first when the + lane narrows; the accessible name keeps the full range available. */ .block.plan.compact .b-title { display: block; overflow: hidden; @@ -1085,16 +1176,14 @@ import { LOD2_HOUR, LOD3_HOUR, LOD_HYSTERESIS, MIN_HOUR_HEIGHT, minimumGridDurat white-space: nowrap; } .block.plan.compact .b-title :global(.ext-icon), - .block.plan.compact .b-meta { - display: none; - } - /* At the lane floor, a coloured bar is the only readable ink. Keep the - Event's full title in `title` and `aria-label` for pointer and keyboard use. */ - .block.plan.cramped .b-content { + .block.plan.compact .b-tags { display: none; } + /* At a narrow lane, keep the clock and title; Tags are the first line to + give up space. Very short blocks still hide content below their line floor. */ .block.cramped .b-pile, - .block.cramped .b-meta { + .block.cramped .b-meta, + .block.cramped .b-tags { display: none; } /* Tier 2: time and tags. Visible once the zoom passes ~50 px an hour and diff --git a/packages/ui/src/components/calendar/ItemPreview.svelte b/packages/ui/src/components/calendar/ItemPreview.svelte index 37085ba33..79ca4db95 100644 --- a/packages/ui/src/components/calendar/ItemPreview.svelte +++ b/packages/ui/src/components/calendar/ItemPreview.svelte @@ -6,9 +6,9 @@ files, never an editable Daily-note body. Read-only subscription Events keep their source identity and offer the Calendar copy action (#431). - Contents (§39): kind, title, the entry's own time or range (never the time - it was typed), the time zone only when it differs from the viewer's, tags, - and the attachments as a deck. Task and Event previews use the full source + Contents (§39): kind, a locale date/time row, title, Tags and attachments. + Calendar cards show Tag leaves with the complete Tag in the tooltip. Task + and Event previews use the full source range even when the clicked day holds one clipped part (#469). Escape and outside dismissal belong to the shared popover shell; the card has no pin or close button (#421; DESIGN §34). @@ -20,13 +20,14 @@ import SquareCheckIcon from '@lucide/svelte/icons/square-check'; import CalendarIcon from '@lucide/svelte/icons/calendar'; import CalendarSyncIcon from '@lucide/svelte/icons/calendar-sync'; - import Pill from '../Pill.svelte'; + import Clock3Icon from '@lucide/svelte/icons/clock-3'; import PillGroup from '../PillGroup.svelte'; import TagPill from '../TagPill.svelte'; import CopyLink from '../CopyLink.svelte'; import PencilIcon from '@lucide/svelte/icons/pencil'; import TrashIcon from '@lucide/svelte/icons/trash-2'; import CopyIcon from '@lucide/svelte/icons/copy'; + import EyeOffIcon from '@lucide/svelte/icons/eye-off'; import PaperclipIcon from '@lucide/svelte/icons/paperclip'; import MicIcon from '@lucide/svelte/icons/mic'; import AttachmentDeck from './AttachmentDeck.svelte'; @@ -48,13 +49,16 @@ type CalendarAttachment, type CalendarEvent, type GridItem, - type PreviewItem + type PreviewItem, + calendarItemActions, + type CalendarActionItem, + type CalendarItemAction, + type CalendarItemActionId } from './model'; interface Props { item: PreviewItem; ontag?: (tag: string) => void; - onopenday?: (date: string) => void; /** Open the shared Composer in edit mode for this item. */ onedit?: () => void; /** Duplicate this item through the server's single-writer API. */ @@ -64,13 +68,15 @@ /** Start the shared Composer voice recorder for this Journal entry (#421). */ onrecordvoice?: () => void; ondelete?: () => void; + /** Hide the current preview surface without changing the Calendar item. */ + onhide?: () => void; /** Long text becomes a linked note (#42 / C7). */ onnote?: (title: string, body: string) => Promise<boolean>; /** A linked note this session created for the entry, to open it. */ noteId?: string | null; /** Open a link inside the app (the page's client-side navigation). */ onnavigate?: (href: string) => void; - /** Remove one Missing Log attachment from its Daily note. */ + /** Remove one attachment link, live or Missing, from the Journal entry. */ onremoveattachment?: (attachment: CalendarAttachment) => void; /** Close the popover (Esc inside the attachment deck). */ onclose?: () => void; @@ -80,7 +86,18 @@ oneditstart?: () => void; } - let { item, ontag, onopenday, onedit, oneditstart, onduplicate, onattachment, onrecordvoice, ondelete, onnote, noteId = null, onnavigate, onremoveattachment, onclose, oncopyevent }: Props = $props(); + let { item, ontag, onedit, oneditstart, onduplicate, onattachment, onrecordvoice, ondelete, onhide, onnote, onnavigate, onremoveattachment, onclose, oncopyevent }: Props = $props(); + + const ACTION_ICONS = { + 'copy-link': CopyIcon, + 'attach-file': PaperclipIcon, + 'voice-memo': MicIcon, + edit: PencilIcon, + hide: EyeOffIcon, + duplicate: CopyIcon, + 'copy-to-calendar': CalendarIcon, + delete: TrashIcon + }; /** Where one attachment opens: a Note by its identity, a file in Quick Look, a web link as is. */ function attachmentHref(attachment: CalendarAttachment): string | null { @@ -105,17 +122,41 @@ return entry.kind === 'photo' ? photoHref(entry.itemId) : fileHref(entry.itemId); } - const spanDay = (at: Date) => formatLongDate(at, { weekday: 'short', month: 'short', year: false }); - /** The event's own range: its whole span with the days when it crosses midnight, else this day's times. */ - function eventRange(ev: CalendarEvent): string { - const start = ev.startsAt ? new Date(ev.startsAt) : null; - const end = ev.endsAt ? new Date(ev.endsAt) : null; - if (start && end && !Number.isNaN(start.getTime()) && !Number.isNaN(end.getTime()) && start.toDateString() !== new Date(end.getTime() - 1).toDateString()) { - return `${spanDay(start)} ${formatTime(start)} – ${spanDay(end)} ${formatTime(end)}`; - } - return `${ev.start ? formatClockTime(ev.start) : ''}${ev.end ? ` – ${formatClockTime(ev.end)}` : ''}`; + /** The stable per-item URL used by the title-row Copy link control (#33, #628). */ + function actionHref(entry: CalendarActionItem): string { + if (entry.kind === 'log') return logEntryHref(entry.date, entry.log.id); + if (entry.kind === 'event') return eventHref(entry.event.id, entry.date, entry.event.sourceId); + return taskHref(entry.task.id); + } + + /** The card and the context menu read this same action order and availability. */ + function previewActions(entry: CalendarActionItem): CalendarItemAction[] { + const stable = entry.kind === 'log' ? !!entry.log.id : true; + return calendarItemActions(entry, { + 'copy-link': stable, + 'attach-file': entry.kind === 'log' && stable && !!onattachment, + 'voice-memo': entry.kind === 'log' && stable && !!onrecordvoice, + edit: stable && !!onedit && !(entry.kind === 'event' && entry.event.readOnly), + hide: !!onhide, + duplicate: stable && !!onduplicate, + 'copy-to-calendar': entry.kind === 'event' && entry.event.readOnly && !!oncopyevent, + delete: entry.kind === 'log' && stable && !!ondelete + }); } + /** Run one action from the shared Calendar action list. */ + function runPreviewAction(action: CalendarItemActionId, entry: CalendarActionItem): void { + if (action === 'attach-file') onattachment?.(); + else if (action === 'voice-memo') onrecordvoice?.(); + else if (action === 'edit') startEdit(); + else if (action === 'hide') onhide?.(); + else if (action === 'duplicate') onduplicate?.(); + else if (action === 'copy-to-calendar' && entry.kind === 'event') void copyEvent(entry.event, entry.date); + else if (action === 'delete') ondelete?.(); + } + + const spanDay = (at: Date) => formatLongDate(at, { weekday: 'short', month: 'short', year: false }); + const ITEM_WORDS: Record<GridItem['kind'], [string, string]> = { photo: ['photo', 'photos'], file: ['file', 'files'], @@ -156,7 +197,7 @@ } const zone = systemTimeZone(); - const dayLabel = $derived(formatLongDate(item.date, { weekday: 'long', year: false })); + const dayLabel = $derived(formatLongDate(item.date, { weekday: 'long', year: true })); const compactDayLabel = $derived(formatLongDate(item.date, { weekday: 'short', weekdaySeparator: ' ', month: 'short', year: false })); const ROLE: Record<string, string> = { @@ -189,25 +230,62 @@ {#snippet titleContent(parts: InlineTagPart[])} {#each parts as part, index (index)} - {#if part.kind === 'tag'}<TagPill tag={part.tag} onclick={ontag} />{:else}{part.text}{/if} + {#if part.kind === 'tag'}<TagPill tag={part.tag} leafOnly onclick={ontag} />{:else}{part.text}{/if} {/each} {/snippet} +{#snippet actionGroup(actions: CalendarItemAction[], group: 'leading' | 'trailing', label: string, entry: CalendarActionItem)} + {@const entries = actions.filter((action) => action.group === group)} + {#if entries.length > 0} + <PillGroup {label} buttonSize="compact"> + {#each entries as action (action.id)} + {@const Icon = ACTION_ICONS[action.id]} + <button + type="button" + class="pg-btn preview-action" + class:danger={action.destructive} + aria-label={action.accessibleLabel} + data-calendar-action={action.id} + {...tip(action.label, action.shortcut)} + disabled={action.id === 'copy-to-calendar' && copyingEvent} + onclick={() => runPreviewAction(action.id, entry)} + ><Icon aria-hidden="true" /><span>{action.id === 'copy-to-calendar' && copyingEvent ? 'Copying…' : action.label}</span></button> + {/each} + </PillGroup> + {/if} +{/snippet} + +{#snippet itemActionRow(actions: CalendarItemAction[], label: string, entry: CalendarActionItem)} + <div class="actions"> + {@render actionGroup(actions, 'leading', `${label} attachment actions`, entry)} + {@render actionGroup(actions, 'trailing', `${label} actions`, entry)} + </div> +{/snippet} + {#if item.kind === 'log'} {@const log = item.log} {@const title = titlePresentation(log.title || 'Untitled entry', log.tags)} + {@const actions = previewActions(item)} <article class="preview" aria-label="Journal"> - <p class="eyebrow"><span class="kind-dot actual" aria-hidden="true"></span>Journal</p> - <h3 class="title">{@render titleContent(title.parts)}</h3> - <p class="meta"> + <p class="eyebrow"><FileTextIcon aria-hidden="true" />Journal</p> + <div class="title-row"> + <h3 class="title">{@render titleContent(title.parts)}</h3> + {#if actions.some((action) => action.id === 'copy-link')} + <CopyLink variant="segment" href={actionHref(item)} label="Copy link to this Journal entry" class="title-copy" /> + {/if} + </div> + <p class="meta date-time" aria-label="Date and time"> + <CalendarIcon aria-hidden="true" /> <span>{dayLabel}</span> - <span class="tabular">{formatClockTime(log.start)}{#if log.end}&thinsp;–&thinsp;{formatClockTime(log.end)}{/if}</span> + <Clock3Icon aria-hidden="true" /> + <span class="time-chip tabular">{formatClockTime(log.start)}</span> + {#if log.end}<span class="time-chip tabular">{formatClockTime(log.end)}</span>{/if} {#if log.timezone && log.timezone !== zone}<span class="zone">{log.timezone.replace(/_/g, ' ')}</span>{/if} {#if log.place}<span class="place">At {log.place.name}</span>{/if} </p> {#if title.tags.length > 0} <div class="tags" aria-label="Tags"> - {#each title.tags as tag (tag)}<TagPill {tag} onclick={ontag} />{/each} + {#each title.tags as tag (tag)}<TagPill {tag} leafOnly onclick={ontag} />{/each} </div> {/if} {#if log.attachments.length > 0} @@ -216,6 +294,7 @@ <AttachmentDeck attachments={log.attachments} expanded={true} + layout="rows" ontoggle={(next) => { if (!next) onclose?.(); }} @@ -224,41 +303,18 @@ /> </div> {/if} - <div class="actions"> - <PillGroup label="Journal actions" buttonSize="compact"> - <CopyLink href={logEntryHref(item.date, log.id)} label="Copy link to this Journal entry" /> - {#if onedit && log.id} - <button type="button" class="pg-btn" aria-label="Edit Journal entry" {...tip('Edit')} onclick={startEdit}><PencilIcon aria-hidden="true" /></button> - {/if} - {#if onduplicate && log.id} - <button type="button" class="pg-btn" aria-label="Duplicate" {...tip('Duplicate', 'calendar.duplicate')} onclick={onduplicate}><CopyIcon aria-hidden="true" /></button> - {/if} - <!-- Journal is the only Calendar item with an append-attachment route (#421; DESIGN §38). --> - {#if onattachment && log.id} - <button type="button" class="pg-btn" aria-label="Add attachment" {...tip('Add attachment', 'calendar.attach')} onclick={onattachment}><PaperclipIcon aria-hidden="true" /></button> - {/if} - {#if onrecordvoice && log.id} - <button type="button" class="pg-btn" aria-label="Record voice note" {...tip('Record voice note', 'calendar.recordVoice')} onclick={onrecordvoice}><MicIcon aria-hidden="true" /></button> - {/if} - {#if ondelete && log.id} - <button type="button" class="pg-btn danger" aria-label="Delete Journal entry" {...tip('Delete')} onclick={ondelete}><TrashIcon aria-hidden="true" /></button> - {/if} - {#if noteId} - <a class="pg-btn" href={noteHref(noteId)} aria-label="Open note" {...tip('Open note')}><FileTextIcon aria-hidden="true" /></a> - {:else if onopenday} - <button type="button" class="pg-btn" aria-label="Open day" {...tip('Open day')} onclick={() => onopenday(item.date)}> - <CalendarIcon aria-hidden="true" /> - </button> - {/if} - </PillGroup> - </div> + {@render itemActionRow(actions, 'Journal', item)} </article> {:else if item.kind === 'task'} {@const task = item.task} {@const title = titlePresentation(task.title, task.tags)} + {@const actions = previewActions(item)} <article class="preview" aria-label="Task"> <p class="eyebrow"><SquareCheckIcon aria-hidden="true" />Task</p> - <h3 class="title" class:done={task.status === 'done' || task.status === 'cancelled'}>{@render titleContent(title.parts)}</h3> + <div class="title-row"> + <h3 class="title" class:done={task.status === 'done' || task.status === 'cancelled'}>{@render titleContent(title.parts)}</h3> + {#if actions.some((action) => action.id === 'copy-link')}<CopyLink variant="segment" href={actionHref(item)} label="Copy link to this task" class="title-copy" />{/if} + </div> <p class="meta"> <!-- Use the Task's source range so either clipped day opens the same preview (#469). --> {#if task.startDate && task.startClock && task.dueDate && task.dueClock} @@ -277,21 +333,19 @@ </p> {#if title.tags.length > 0} <div class="tags" aria-label="Tags"> - {#each title.tags as tag (tag)}<TagPill {tag} onclick={ontag} />{/each} - </div> - {/if} - <div class="actions"> - <PillGroup label="Task actions" buttonSize="compact"> - <CopyLink href={taskHref(task.id)} label="Copy link to this task" /> - {#if onedit}<button type="button" class="pg-btn" aria-label="Edit task" {...tip('Edit')} onclick={startEdit}><PencilIcon aria-hidden="true" /></button>{/if} - {#if onduplicate}<button type="button" class="pg-btn" aria-label="Duplicate" {...tip('Duplicate', 'calendar.duplicate')} onclick={onduplicate}><CopyIcon aria-hidden="true" /></button>{/if} - </PillGroup> + {#each title.tags as tag (tag)}<TagPill {tag} leafOnly onclick={ontag} />{/each} </div> + {/if} + {@render itemActionRow(actions, 'Task', item)} </article> {:else if item.kind === 'event'} {@const ev = item.event} {@const title = titlePresentation(ev.title, ev.tags)} {@const tint = eventTint(ev.tags, ev.color)} + {@const actions = previewActions(item)} + {@const startAt = ev.startsAt ? new Date(ev.startsAt) : null} + {@const endAt = ev.endsAt ? new Date(ev.endsAt) : null} + {@const crossesDay = !!startAt && !!endAt && !Number.isNaN(startAt.getTime()) && !Number.isNaN(endAt.getTime()) && startAt.toDateString() !== new Date(endAt.getTime() - 1).toDateString()} <article class="preview event" class:past={item.date < localDate()} @@ -305,28 +359,33 @@ aria-label="Event" > <p class="eyebrow"><CalendarSyncIcon aria-hidden="true" />{ev.provider}{#if ev.readOnly} · Read-only{/if}</p> - <h3 class="title">{@render titleContent(title.parts)}</h3> - <p class="meta"> + <div class="title-row"> + <h3 class="title">{@render titleContent(title.parts)}</h3> + {#if actions.some((action) => action.id === 'copy-link')}<CopyLink variant="segment" href={actionHref(item)} label="Copy link to this Event" class="title-copy" />{/if} + </div> + <p class="meta date-time" aria-label="Date and time"> + <CalendarIcon aria-hidden="true" /> <span>{dayLabel}</span> - <span class="tabular">{#if ev.allDay}All day{:else}{eventRange(ev)}{/if}</span> + <Clock3Icon aria-hidden="true" /> + {#if ev.allDay}<span class="time-chip">All day</span> + {:else} + {#if crossesDay && startAt && endAt} + <span class="time-chip tabular">{spanDay(startAt)} {formatTime(startAt)}</span> + <span class="time-chip tabular">{spanDay(endAt)} {formatTime(endAt)}</span> + {:else} + {#if ev.start}<span class="time-chip tabular">{formatClockTime(ev.start)}</span>{/if} + {#if ev.end}<span class="time-chip tabular">{formatClockTime(ev.end)}</span>{/if} + {/if} + {/if} {#if ev.timezone && ev.timezone !== zone}<span class="zone">{ev.timezone.replace(/_/g, ' ')}</span>{/if} </p> {#if ev.location}<p class="meta event-location">{ev.location}</p>{/if} {#if title.tags.length > 0} <div class="tags" aria-label="Tags"> - {#each title.tags as tag (`${ev.id}-${tag}`)}<TagPill {tag} onclick={ontag} />{/each} - </div> - {/if} - <div class="actions"> - <PillGroup label="Event actions" buttonSize="compact"> - <CopyLink href={eventHref(ev.id, item.date, ev.sourceId)} label="Copy link to this event" /> - {#if onedit && !ev.readOnly}<button type="button" class="pg-btn" aria-label="Edit event" {...tip('Edit')} onclick={startEdit}><PencilIcon aria-hidden="true" /></button>{/if} - {#if onduplicate}<button type="button" class="pg-btn" aria-label="Duplicate" {...tip('Duplicate', 'calendar.duplicate')} onclick={onduplicate}><CopyIcon aria-hidden="true" /></button>{/if} - {#if ev.readOnly && oncopyevent} - <Pill as="button" type="button" shape="chip" tone="secondary" disabled={copyingEvent} onclick={() => void copyEvent(ev, item.date)}>{copyingEvent ? 'Copying…' : 'Copy to my calendar'}</Pill> - {/if} - </PillGroup> + {#each title.tags as tag (`${ev.id}-${tag}`)}<TagPill {tag} leafOnly onclick={ontag} />{/each} </div> + {/if} + {@render itemActionRow(actions, 'Event', item)} </article> {:else if item.kind === 'items'} {@const entries = item.items} @@ -384,11 +443,6 @@ {:else} <CopyLink href={calendarHref('day', item.date)} label="Copy link to this day" /> {/if} - {#if onopenday} - <button type="button" class="pg-btn" aria-label="Open day" {...tip('Open day')} onclick={() => onopenday(item.date)}> - <CalendarIcon aria-hidden="true" /> - </button> - {/if} </PillGroup> </div> </article> @@ -431,16 +485,11 @@ {@const listed = stack.kind === 'notes' ? Math.min(8, stack.notes.length) : Math.min(8, stack.files.length)} {#if stack.count > listed}<p class="more">and {stack.count - listed} more</p>{/if} {/if} - {#if onopenday} - <div class="actions"> - <PillGroup label="Day actions" buttonSize="compact"> - <CopyLink href={calendarHref('day', item.date)} label="Copy link to this day" /> - <button type="button" class="pg-btn" aria-label="Open day" {...tip('Open day')} onclick={() => onopenday(item.date)}> - <CalendarIcon aria-hidden="true" /> - </button> - </PillGroup> - </div> - {/if} + <div class="actions"> + <PillGroup label="Day actions" buttonSize="compact"> + <CopyLink href={calendarHref('day', item.date)} label="Copy link to this day" /> + </PillGroup> + </div> </article> {/if} @@ -489,12 +538,6 @@ text-overflow: ellipsis; white-space: nowrap; } - .kind-dot { - width: 8px; - height: 8px; - border-radius: var(--radius-pill); - background: var(--accent); - } .title { margin: 0; padding-right: 40px; @@ -522,6 +565,27 @@ text-decoration: none; } .title-link:hover { text-decoration: underline; } + .title-row { + position: relative; + min-width: 0; + } + .title-row .title { + padding-right: 38px; + } + :global(.title-copy) { + position: absolute; + top: -5px; + right: -6px; + z-index: 1; + opacity: 0; + } + .title-row:hover :global(.title-copy), + .title-row:focus-within :global(.title-copy) { + opacity: 1; + } + @media (hover: none) { + :global(.title-copy) { opacity: 1; } + } .meta { display: flex; flex-wrap: wrap; @@ -540,12 +604,34 @@ .tabular { font-variant-numeric: tabular-nums; } + .date-time { + align-items: center; + gap: 4px 6px; + } + .date-time :global(svg) { + flex: none; + width: 14px; + height: 14px; + color: var(--muted); + } + .time-chip { + padding: 1px 6px; + border-radius: var(--radius-pill); + background: color-mix(in srgb, var(--ink) 7%, var(--surface)); + color: var(--ink); + white-space: nowrap; + } .tags { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; gap: 6px; margin-top: 2px; + max-width: 100%; + overflow-x: auto; + scrollbar-width: none; } + .tags::-webkit-scrollbar { display: none; } + .tags > :global(.pill.tag) { flex: none; } .item-list, .photo-grid { margin: 2px 0 0; @@ -676,8 +762,32 @@ } .actions { display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; margin-top: 8px; } + .actions :global(.pill-group) { + max-width: 100%; + } + .actions :global(.pill-group:last-child) { + margin-left: auto; + } + .preview-action { + display: inline-flex; + align-items: center; + gap: 5px; + white-space: nowrap; + } + .preview-action :global(svg) { + flex: none; + width: 12px; + height: 12px; + } + .preview-action.danger { + color: var(--danger); + } .danger:hover { color: var(--danger); } diff --git a/packages/ui/src/components/calendar/MonthGrid.svelte b/packages/ui/src/components/calendar/MonthGrid.svelte index 9b2aebb7f..4d23ef827 100644 --- a/packages/ui/src/components/calendar/MonthGrid.svelte +++ b/packages/ui/src/components/calendar/MonthGrid.svelte @@ -118,7 +118,7 @@ class:selected={selectedKey === row.key} class:continues-before={row.part.continuesBefore} class:continues-after={row.part.continuesAfter} - aria-label={log.pending ? `Sending Log entry at ${formatClockTime(row.part.start)}: ${log.title}` : `Log: ${log.title}${row.part.continuesBefore ? ', from yesterday' : row.part.continuesAfter ? ', continues tomorrow' : ''}`} + aria-label={log.pending ? `Sending Journal entry at ${formatClockTime(row.part.start)}: ${log.title}` : `Journal: ${log.title}${row.part.continuesBefore ? ', from yesterday' : row.part.continuesAfter ? ', continues tomorrow' : ''}`} aria-disabled={log.pending || undefined} onclick={(event) => !log.pending && onselect({ kind: 'log', date: row.part.sourceDate, log }, event.currentTarget as HTMLElement, row.key)} > diff --git a/packages/ui/src/components/calendar/TimeGrid.svelte b/packages/ui/src/components/calendar/TimeGrid.svelte index 2c0bb7007..a14ede60d 100644 --- a/packages/ui/src/components/calendar/TimeGrid.svelte +++ b/packages/ui/src/components/calendar/TimeGrid.svelte @@ -1261,7 +1261,7 @@ const time = `${formatClockTime(item.log.start)}${item.log.end ? ` to ${formatClockTime(item.log.end)}` : ''}`; const tags = item.log.tags.length ? `, tags ${item.log.tags.map((tag) => tag.replace(/^area\//, '')).join(', ')}` : ''; const files = item.log.attachments.length ? `, ${item.log.attachments.length} ${item.log.attachments.length === 1 ? 'attachment' : 'attachments'}` : ''; - return `Log entry ${time}: ${item.log.title || 'Untitled entry'}${tags}${files}`; + return `Journal ${time}: ${item.log.title || 'Untitled entry'}${tags}${files}`; } /** Show the familiar title in the shared all-items list. */ diff --git a/packages/ui/src/components/calendar/attachments.ts b/packages/ui/src/components/calendar/attachments.ts index 87bbc5dc3..48aa9f5a8 100644 --- a/packages/ui/src/components/calendar/attachments.ts +++ b/packages/ui/src/components/calendar/attachments.ts @@ -3,11 +3,11 @@ * * The range API sends each attachment's Daily-note text and target plus, when * the viewer can see the target, its Files identity and thumbnail hash. This - * module turns that into what the deck draws: a display name, a glyph family - * for files without a thumbnail, and an accessible label. It holds no state - * and reads nothing but its input, so the deck and its tests share it. + * module turns that into the deck's display name, kind label, glyph family, + * accessible label and stable share URL (#628). It holds no state and reads + * nothing but its input, so the deck and its tests share the same rules. */ -import type { CalendarAttachment } from './model'; +import { fileHref, noteHref, type CalendarAttachment } from './model'; export type AttachmentFamily = 'image' | 'video' | 'audio' | 'archive' | 'document' | 'other'; @@ -64,6 +64,31 @@ export function attachmentFileName(target: string): string { return baseName(target); } +/** + * Return the short kind label shown in Calendar preview rows (#628). The full + * indexed name remains in the shared tooltip so a narrow row stays scannable. + */ +export function attachmentKindLabel(attachment: CalendarAttachment): string { + if (SCHEME.test(attachment.target)) return 'Link'; + if (attachment.kind === 'task') return 'Task'; + const family = attachmentFamily(attachment.target, attachment.mediaType); + if (family === 'audio') return 'Voice Memo'; + if (family === 'image') return 'Photo'; + if (family === 'video') return 'Video'; + if (family === 'document') { + const name = attachment.target.split(/[?#]/)[0]!.split('/').pop() ?? ''; + return attachment.mediaType === 'application/pdf' || /\.pdf$/i.test(name) ? 'PDF' : 'Document'; + } + return 'File'; +} + +/** Build a shareable link only from stable API identities or a web URL (#628). */ +export function attachmentShareHref(attachment: CalendarAttachment): string | null { + if (attachment.noteId) return noteHref(attachment.noteId); + if (attachment.itemId) return fileHref(attachment.itemId); + return /^https?:\/\//i.test(attachment.target) ? attachment.target : null; +} + /** Build bounded view data from real API attachments without resolving paths on the client. */ export function attachmentRefs(attachments: readonly CalendarAttachment[]): AttachmentRef[] { const seen = new Set<string>(); diff --git a/packages/ui/src/components/calendar/model.ts b/packages/ui/src/components/calendar/model.ts index 8f4bed9af..8362e3085 100644 --- a/packages/ui/src/components/calendar/model.ts +++ b/packages/ui/src/components/calendar/model.ts @@ -260,6 +260,48 @@ export type PreviewItem = | { kind: 'stack'; date: string; stack: ActivityStack } | { kind: 'items'; date: string; start: number; end: number; items: GridItem[] }; +/** One shared Calendar item's hover card and context-menu action definition (#581, #628). */ +export type CalendarItemActionId = 'copy-link' | 'attach-file' | 'voice-memo' | 'edit' | 'hide' | 'duplicate' | 'copy-to-calendar' | 'delete'; +export type CalendarItemActionGroup = 'title' | 'leading' | 'trailing'; + +export interface CalendarItemAction { + id: CalendarItemActionId; + label: string; + accessibleLabel: string; + group: CalendarItemActionGroup; + icon: 'link' | 'paperclip' | 'mic' | 'pencil' | 'eye-off' | 'copy' | 'calendar' | 'trash'; + shortcut?: 'calendar.attach' | 'calendar.recordVoice' | 'calendar.duplicate'; + destructive?: boolean; +} + +export type CalendarActionItem = Extract<PreviewItem, { kind: 'log' | 'event' | 'task' }>; +export type CalendarItemActionAvailability = Partial<Record<CalendarItemActionId, boolean>>; + +/** + * Build the one ordered action list used by Calendar's hover card and item + * context menu. Callers only supply availability; labels, order and groups + * stay shared as DESIGN §34 and issue #581 require. + */ +export function calendarItemActions(item: CalendarActionItem, available: CalendarItemActionAvailability): CalendarItemAction[] { + const name = item.kind === 'log' ? 'Journal entry' : item.kind === 'event' ? 'Event' : 'Task'; + const definitions: CalendarItemAction[] = [ + { id: 'copy-link', label: 'Copy link', accessibleLabel: `Copy link to this ${name}`, group: 'title', icon: 'link' }, + { id: 'attach-file', label: 'Attach File', accessibleLabel: 'Attach File', group: 'leading', icon: 'paperclip', shortcut: 'calendar.attach' }, + { id: 'voice-memo', label: 'Voice Memo', accessibleLabel: 'Voice Memo', group: 'leading', icon: 'mic', shortcut: 'calendar.recordVoice' }, + { id: 'edit', label: 'Edit', accessibleLabel: `Edit ${name}`, group: 'trailing', icon: 'pencil' }, + { id: 'hide', label: 'Hide', accessibleLabel: 'Hide', group: 'trailing', icon: 'eye-off' }, + { id: 'duplicate', label: 'Duplicate', accessibleLabel: 'Duplicate', group: 'trailing', icon: 'copy', shortcut: 'calendar.duplicate' }, + { id: 'copy-to-calendar', label: 'Copy to my calendar', accessibleLabel: 'Copy Event to my calendar', group: 'trailing', icon: 'calendar' }, + { id: 'delete', label: 'Delete', accessibleLabel: `Delete ${name}`, group: 'trailing', icon: 'trash', destructive: true } + ]; + return definitions.filter((action) => { + if (available[action.id] !== true) return false; + if ((action.id === 'attach-file' || action.id === 'voice-memo' || action.id === 'delete') && item.kind !== 'log') return false; + if (action.id === 'copy-to-calendar' && (item.kind !== 'event' || !item.event.readOnly)) return false; + return true; + }); +} + /** A range selected on the hour grid for creation, in minutes after midnight. */ export interface CreateRange { date: string; show commit cd435e354 (2026-10-01) introduced a visible action-label span in ItemPreview.svelte. The later icon-only fix bc9a40929 is not in the production base df92d4da1, which still renders the span.
Author
Owner

Finding: commit cd435e354 on 2026-10-01 introduced the visible action-label span in ItemPreview.svelte. The later icon-only correction bc9a40929 is absent from production base df92d4da1, which still renders the span.

Finding: commit cd435e354 on 2026-10-01 introduced the visible action-label span in ItemPreview.svelte. The later icon-only correction bc9a40929 is absent from production base df92d4da1, which still renders the span.
Author
Owner

Deployed to production 2026-10-05 09:42 CEST in fix batch 1 (d1b8371f5).

Deployed to production 2026-10-05 09:42 CEST in fix batch 1 (d1b8371f5).
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal#1098
No description provided.