From f9945092899afb2be7ffcd5c4b44ec42eff2ddf5 Mon Sep 17 00:00:00 2001 From: xarmian Date: Fri, 24 Jul 2026 16:27:09 -0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20card=20anatomy=20=E2=80=94=20Chip?= =?UTF-8?q?=20status/priority,=20card=20tokens,=20violet=20ring,=20lane=20?= =?UTF-8?q?accents=20(TASK-2293)=20(#1023)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): card anatomy per the refresh mock — Chip status/priority, card tokens, violet selection ring, lane accents (TASK-2293) PLAN-2290 Phase 3, PR A. ItemCard (shared by Board/List/starred/tags/roles): - Skin: --card-bg/--card-border/--radius-lg/--shadow-card; hover = border-strong (no transform — svelte-dnd-action owns card transforms); .focused becomes the mock's violet ring + glow (e2e asserts the CLASS, which is unchanged). - Anatomy: ref stays top-left; star moves to the right cluster before the kebab (ONE auto margin on the star — competing autos split the gap). - Status/priority render as Chip primitives (tinted pills; status keeps click-cycle + pulse via Chip props; labels Title Case, no more uppercase). - Tags become purple-tinted pills; leading separator before parent chip dropped (chips separate visually). - Dead CSS removed (meta-status family, status-pulse keyframes). Lane accents: columnAccentClassFor (shareView — shared with the public fork by construction) gains col-open for open/new/todo/planned; BoardView + PublicBoardView underline it --status-blue. Default underline unchanged for custom vocabularies. Gates: svelte-check 0 errors, 488 tests; board+pane screenshots verified in both themes. * fix(web): consolidate BoardView lane accents onto shared mapper + AA chip text in light theme Codex findings on #1023: (1) BoardView had its OWN columnCssClass — a fifth parallel status-color-ish map, so only public boards got col-open; it now delegates to shareView.columnAccentClassFor (in-app and public boards can't drift, and custom terminal lanes now read as done in-app too). (2) New --chip-text-mix token (100% dark / 72% light) darkens chip text on light surfaces — all chip colors verified >=5.8:1 on white (computed). * refactor(web): columnAccentClassFor moves to $lib/utils/fieldColors (Codex — dependency direction); shareView re-exports --- web/src/app.css | 5 + .../components/collections/BoardView.svelte | 21 +-- .../components/collections/ItemCard.svelte | 136 +++++++----------- web/src/lib/components/common/Chip.svelte | 4 +- .../components/share/PublicBoardView.svelte | 4 + web/src/lib/components/share/shareView.ts | 19 +-- web/src/lib/utils/fieldColors.ts | 25 ++++ 7 files changed, 100 insertions(+), 114 deletions(-) diff --git a/web/src/app.css b/web/src/app.css index 44811ecd..7f374a6e 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -25,6 +25,9 @@ /* Tint strength for Chip/badge fills (color-mix percentage). */ --chip-alpha: 16%; + /* Chip TEXT darkening: 100% = raw accent (dark theme); light theme + drops to ~72% mixed toward black so small colored labels hold AA. */ + --chip-text-mix: 100%; /* Brand accent. --accent-blue is aliased to it because ~95% of its 462 call sites mean "brand accent", not "blue" (see PLAN-2290); the @@ -660,6 +663,7 @@ dialog.attachment-image-lightbox .attachment-image-lightbox-close:hover { --card-border: #d0d0dc; --shadow-card: 0 1px 3px rgba(23, 23, 40, 0.1); --chip-alpha: 12%; + --chip-text-mix: 72%; --accent-primary: #7c3aed; --accent-primary-soft: #8b5cf6; --accent-primary-strong: #7c3aed; @@ -698,6 +702,7 @@ dialog.attachment-image-lightbox .attachment-image-lightbox-close:hover { --card-border: #d0d0dc; --shadow-card: 0 1px 3px rgba(23, 23, 40, 0.1); --chip-alpha: 12%; + --chip-text-mix: 72%; --accent-primary: #7c3aed; --accent-primary-soft: #8b5cf6; --accent-primary-strong: #7c3aed; diff --git a/web/src/lib/components/collections/BoardView.svelte b/web/src/lib/components/collections/BoardView.svelte index 8e99f1fc..519cc4ea 100644 --- a/web/src/lib/components/collections/BoardView.svelte +++ b/web/src/lib/components/collections/BoardView.svelte @@ -4,6 +4,7 @@ import { itemComparator, type SortMode } from '$lib/collections/itemSort'; import { reorderGroup, disabledDirections, adjacentColumn, type ReorderDirection } from '$lib/collections/reorder'; import { bucketByColumn, UNCATEGORIZED } from '$lib/collections/boardColumns'; + import { columnAccentClassFor } from '$lib/utils/fieldColors'; import { dndzone, TRIGGERS, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action'; import type { DndEvent } from 'svelte-dnd-action'; import ItemCard from './ItemCard.svelte'; @@ -453,17 +454,13 @@ return disabled; } + // Delegates to the shared accent mapper (shareView.ts) so the in-app + // board and the public-share fork can never drift again — this was the + // FIFTH parallel status-color-ish implementation (PR #1023 Codex catch). + // Bonus: terminal_options-aware, so custom "Shipped"-style lanes read + // as done here too, matching public boards. function columnCssClass(value: string): string { - switch (value) { - case 'in_progress': - return 'col-in-progress'; - case 'done': - return 'col-done'; - case 'blocked': - return 'col-blocked'; - default: - return ''; - } + return columnAccentClassFor(field, value); } @@ -712,6 +709,10 @@ gap: var(--space-1); } + .column-header.col-open { + border-bottom-color: var(--status-blue); + } + .column-header.col-in-progress { border-bottom-color: var(--accent-amber); } diff --git a/web/src/lib/components/collections/ItemCard.svelte b/web/src/lib/components/collections/ItemCard.svelte index 14acfd61..3e7a17b5 100644 --- a/web/src/lib/components/collections/ItemCard.svelte +++ b/web/src/lib/components/collections/ItemCard.svelte @@ -7,6 +7,7 @@ import { copyToClipboard } from '$lib/utils/clipboard'; import { relativeTime } from '$lib/utils/markdown'; import { statusColor, priorityColor, formatFieldLabel as formatLabel } from '$lib/utils/fieldColors'; + import Chip from '$lib/components/common/Chip.svelte'; import ItemActionsMenu from './ItemActionsMenu.svelte'; import type { ReorderDirection } from '$lib/collections/reorder'; import { shouldOpenInPane } from './itemCardClick'; @@ -173,14 +174,6 @@ {/if}
- {#if showCollection && item.collection_name} {#if item.collection_icon}{item.collection_icon} {/if}{item.collection_name} @@ -208,6 +201,14 @@ {copied ? `Copied ${itemRef}` : ''} {/if} + {#if onReorderItem} {#if statusField && fields.status} {#if statusCyclable} - + {formatLabel(fields.status)} + {:else} - - {formatLabel(fields.status).toUpperCase()} - + + {formatLabel(fields.status)} + {/if} {/if} {#if priorityField && fields.priority} - {#if statusField && fields.status}·{/if} - + {formatLabel(fields.priority)} - + {/if} {#if item.parent_title} - · {@const parentLabel = item.parent_ref ? `${item.parent_ref}: ${item.parent_title}` : item.parent_title} {parentLabel} {/if} @@ -294,9 +293,10 @@ display: flex; flex-direction: column; gap: var(--space-2); - background: var(--bg-primary); - border: 1px solid var(--border); - border-radius: var(--radius); + background: var(--card-bg, var(--bg-primary)); + border: 1px solid var(--card-border, var(--border)); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card, none); padding: var(--space-4) var(--space-5); text-decoration: none; color: inherit; @@ -338,15 +338,19 @@ transform: scale(0.95); } - .item-card:hover, - .item-card.focused { - background: var(--bg-hover); + .item-card:hover { + border-color: var(--border-strong, var(--border)); text-decoration: none; } + /* Selected-in-pane ring (the mock's violet glow). E2E asserts the + .focused CLASS, not these styles — keep the class name stable. */ .item-card.focused { - outline: 2px solid var(--accent-blue); - outline-offset: -2px; + border-color: color-mix(in srgb, var(--accent-primary, var(--accent-blue)) 60%, transparent); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--accent-primary, var(--accent-blue)) 45%, transparent), + 0 4px 18px color-mix(in srgb, var(--accent-primary, var(--accent-blue)) 22%, transparent); + text-decoration: none; } .item-card.compact { @@ -359,10 +363,11 @@ gap: var(--space-2); } - /* Reorder kebab sits at the far right of the top row. When a PR badge - is present the `.has-pr` padding-right reservation keeps it clear of - the absolutely-positioned badge (kebab lands just left of it). */ - .card-top-row :global(.item-actions-menu) { + /* The star + reorder kebab form the right cluster (mock anatomy: ref + left, star/kebab right). ONE auto margin — on the star — pushes the + cluster right; the kebab follows it. Two competing auto margins + would split the free space (see .meta-spacer note below). */ + .card-top-row .star-btn { margin-left: auto; } @@ -489,60 +494,14 @@ min-width: 0; } - .meta-status { - font-size: 0.7em; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.02em; - white-space: nowrap; - } - - .meta-status-btn { - border: none; - background: none; - cursor: pointer; - padding: 0; - font-family: inherit; - line-height: inherit; - transition: filter 0.1s, transform 0.1s; - } - - .meta-status-btn:hover { - filter: brightness(1.3); - transform: scale(1.05); - } - - .meta-status-btn:active { - transform: scale(0.95); - } - - .meta-status-btn.pulsing { - animation: status-pulse 0.3s ease-out; - } - - @keyframes status-pulse { - 0% { - text-shadow: 0 0 0 currentColor; - } - 70% { - text-shadow: 0 0 8px currentColor; - } - 100% { - text-shadow: 0 0 0 currentColor; - } - } + /* Status + priority render as Chip primitives now (tinted pills per + the refresh mock); the chip carries the click-cycle + pulse. */ .meta-sep { font-size: 0.7em; color: var(--text-muted); } - .meta-priority { - font-size: 0.7em; - font-weight: 600; - white-space: nowrap; - } - .meta-parent { font-size: 0.7em; font-weight: 500; @@ -596,16 +555,18 @@ gap: var(--space-1, 0.25rem); } + /* Tag pills use the Chip tint treatment (purple family per the mock). */ .card-tag { display: inline-flex; align-items: center; - padding: 0.05em 0.45em; + padding: 0.1em 0.55em; font-size: 0.68em; line-height: 1.5; - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: 999px; - color: var(--text-secondary); + background: color-mix(in srgb, var(--accent-purple) var(--chip-alpha, 16%), transparent); + border: none; + border-radius: 6px; + color: color-mix(in srgb, var(--accent-purple) var(--chip-text-mix, 100%), #000); + font-weight: 500; cursor: pointer; max-width: 12rem; overflow: hidden; @@ -614,8 +575,7 @@ } .card-tag:hover { - color: var(--text-primary); - border-color: var(--text-tertiary, var(--text-secondary)); + filter: brightness(1.15); } .card-progress { diff --git a/web/src/lib/components/common/Chip.svelte b/web/src/lib/components/common/Chip.svelte index f3de37fa..630fc482 100644 --- a/web/src/lib/components/common/Chip.svelte +++ b/web/src/lib/components/common/Chip.svelte @@ -65,7 +65,9 @@ line-height: 1.5; white-space: nowrap; background: color-mix(in srgb, var(--chip-c) var(--chip-alpha, 16%), transparent); - color: var(--chip-c); + /* --chip-text-mix darkens chip text in light mode (100% dark / ~75% + light) so small colored labels hold AA on white-ish surfaces. */ + color: color-mix(in srgb, var(--chip-c) var(--chip-text-mix, 100%), #000); } .chip.md { diff --git a/web/src/lib/components/share/PublicBoardView.svelte b/web/src/lib/components/share/PublicBoardView.svelte index 398f1de1..7eb77f15 100644 --- a/web/src/lib/components/share/PublicBoardView.svelte +++ b/web/src/lib/components/share/PublicBoardView.svelte @@ -93,6 +93,10 @@ font-size: 0.9em; } + .column-header.col-open { + border-bottom-color: var(--status-blue); + } + .column-header.col-in-progress { border-bottom-color: var(--accent-amber); } diff --git a/web/src/lib/components/share/shareView.ts b/web/src/lib/components/share/shareView.ts index 4a46b659..dc3c726e 100644 --- a/web/src/lib/components/share/shareView.ts +++ b/web/src/lib/components/share/shareView.ts @@ -327,21 +327,10 @@ export function fieldValueColor(field: FieldDef | undefined, value: string): str return 'var(--text-muted)'; } -/** Schema-driven board-column accent class. Literal status values map to the - * in-app palette; a custom terminal option falls back to the `done` accent so - * a "Shipped"/"Closed" column still reads as a finished lane. */ -export function columnAccentClassFor(field: FieldDef | undefined, value: string): string { - switch (value) { - case 'in_progress': - return 'col-in-progress'; - case 'done': - return 'col-done'; - case 'blocked': - return 'col-blocked'; - } - if (value && field?.terminal_options?.includes(value)) return 'col-done'; - return ''; -} +/** Board-column accent class — canonical implementation lives in + * $lib/utils/fieldColors (neutral module); re-exported here so the + * public-share import surface is unchanged. */ +export { columnAccentClassFor } from '$lib/utils/fieldColors'; /** Group `items` by `groupField` value, in option order with any extra values diff --git a/web/src/lib/utils/fieldColors.ts b/web/src/lib/utils/fieldColors.ts index 7e4ab5b2..eafad065 100644 --- a/web/src/lib/utils/fieldColors.ts +++ b/web/src/lib/utils/fieldColors.ts @@ -103,3 +103,28 @@ export function hasCanonicalStatus(value: string): boolean { export function formatFieldLabel(value: string): string { return value.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); } + +/** Board-column accent class for a lane value. Literal statuses map to the + * canonical lane palette; a custom terminal option (e.g. "shipped") reads + * as a done lane. Shared by BoardView AND the public-share fork + * (shareView.ts re-exports) so lane accents can never drift between them. */ +export function columnAccentClassFor( + field: { terminal_options?: string[] } | undefined, + value: string +): string { + switch (value) { + case 'open': + case 'new': + case 'todo': + case 'planned': + return 'col-open'; + case 'in_progress': + return 'col-in-progress'; + case 'done': + return 'col-done'; + case 'blocked': + return 'col-blocked'; + } + if (value && field?.terminal_options?.includes(value)) return 'col-done'; + return ''; +}