feat(web): card anatomy — Chip status/priority, card tokens, violet ring, lane accents (TASK-2293) (#1023)

* 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
This commit is contained in:
xarmian
2026-07-24 16:27:09 -04:00
committed by GitHub
parent 01a94d93a8
commit f994509289
7 changed files with 100 additions and 114 deletions
+5
View File
@@ -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;
@@ -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);
}
</script>
@@ -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);
}
@@ -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 @@
</button>
{/if}
<div class="card-top-row">
<button
class="star-btn"
class:starred
onclick={toggleStar}
title={starred ? 'Unstar' : 'Star'}
>
{starred ? '★' : '☆'}
</button>
{#if showCollection && item.collection_name}
<span class="collection-badge">
{#if item.collection_icon}{item.collection_icon} {/if}{item.collection_name}
@@ -208,6 +201,14 @@
<span class="sr-only" aria-live="polite">{copied ? `Copied ${itemRef}` : ''}</span>
</span>
{/if}
<button
class="star-btn"
class:starred
onclick={toggleStar}
title={starred ? 'Unstar' : 'Star'}
>
{starred ? '★' : '☆'}
</button>
{#if onReorderItem}
<ItemActionsMenu
{item}
@@ -227,29 +228,27 @@
<div class="card-meta">
{#if statusField && fields.status}
{#if statusCyclable}
<button
class="meta-status meta-status-btn"
class:pulsing
style:color={statusColor(fields.status)}
<Chip
size="sm"
color={statusColor(fields.status)}
pulse={pulsing}
onclick={cycleStatus}
title="Click to cycle status"
>
{formatLabel(fields.status).toUpperCase()}
</button>
{formatLabel(fields.status)}
</Chip>
{:else}
<span class="meta-status" style:color={statusColor(fields.status)}>
{formatLabel(fields.status).toUpperCase()}
</span>
<Chip size="sm" color={statusColor(fields.status)}>
{formatLabel(fields.status)}
</Chip>
{/if}
{/if}
{#if priorityField && fields.priority}
{#if statusField && fields.status}<span class="meta-sep">&middot;</span>{/if}
<span class="meta-priority" style:color={priorityColor(fields.priority)}>
<Chip size="sm" color={priorityColor(fields.priority)}>
{formatLabel(fields.priority)}
</span>
</Chip>
{/if}
{#if item.parent_title}
<span class="meta-sep">&middot;</span>
{@const parentLabel = item.parent_ref ? `${item.parent_ref}: ${item.parent_title}` : item.parent_title}
<span class="meta-parent" title={parentLabel}>{parentLabel}</span>
{/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 {
+3 -1
View File
@@ -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 {
@@ -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);
}
+4 -15
View File
@@ -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
+25
View File
@@ -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 '';
}