mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
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:
@@ -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">·</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">·</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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user