refactor(ui): the badge layer gets one owner per shape (#3132 items 1-3)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 33s
CI & Build / TypeScript typecheck (push) Successful in 40s
CI & Build / Python tests (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 39s

ITEM 1 — the dead canon. StatusBadge is recorded canon (#2960) and its only
consumer, TaskCard, has been unreachable since 2026-04-08, when
TasksListView was deleted in favour of the Knowledge view. Four and a half
months of a canon that rendered nowhere, which is worse than no canon: a
session pulls #2960, builds from it, and matches a component nobody has
seen. TaskCard is deleted (rule 22), and the canon is made real by adoption
rather than by being left as a museum piece.

ITEM 2 — MY OWN ISSUE OVERSTATED THIS, and the correction is the finding.
"Three scoped re-spellings" assumed one shape spelled thrice. Reading them:

  KnowledgeView   a task-status chip, just smaller     -> a real duplicate
  WorkspaceTaskPanel  a CLICKABLE cycler: pointer,
                  outlined, transparent background     -> a control, not a chip
  ProjectView     PROJECT lifecycle (active/paused/
                  completed/archived)                  -> a different vocabulary

Only the first was ever a duplicate. The others shared a class NAME and
nothing else — which is exactly what would make a future consolidation merge
three unrelated things. So: KnowledgeView adopts StatusBadge/PriorityBadge
via the `compact` variant the canon already anticipated ("interactive/compact
re-spellings are variants of it"); the cycler becomes `.status-cycler`; and
project status becomes its own vocabulary.

And there was a FOURTH, in ProjectListView — the genuine duplicate of
ProjectView's project pill, differing by the amounts two hands differ by:
0.68rem vs 0.7rem, a 14% tint vs 15%, one bordered and one not. Both now use
one ProjectStatusBadge. `statusLabel` went with its only caller.

ITEM 3 — weight. StatusBadge and PriorityBadge used font-weight 600; the
house style allows 400 and 500 only. Also "In Progress" -> "In progress",
which was invisible under `text-transform: uppercase` and becomes visible the
moment the compact variant turns that off.

THE GUARD MISSED FOUR LIVE SITES, which is the part worth keeping. The
project pills painted a hue on an inline `color-mix` tint of itself —
measured 1.61-2.39:1 — and the checker only knew the `--fs-X-bg` token form.
Widened, it finds 48 across the app, 26 of them --fs-accent.

That backlog is not this task, so the check now splits: it GATES the token
form, which is clean, and REPORTS the inline form with a count and its worst
offenders. A gate nobody can satisfy today gets switched off, and then it
guards nothing. Gate re-verified by reintroducing a defect — exit 1 with it,
exit 0 without.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 20:41:58 -04:00
co-authored by Claude Opus 5
parent 0c74dc8275
commit ce1376edc9
9 changed files with 160 additions and 330 deletions
+12 -2
View File
@@ -3,6 +3,8 @@ import type { TaskPriority } from "@/types/task";
const props = defineProps<{
priority: TaskPriority;
/** Dense surfaces — see StatusBadge. */
compact?: boolean;
}>();
const labels: Record<TaskPriority, string> = {
@@ -16,7 +18,7 @@ const labels: Record<TaskPriority, string> = {
<template>
<span
v-if="props.priority !== 'none'"
:class="['priority-badge', `priority-${props.priority}`]"
:class="['priority-badge', `priority-${props.priority}`, { compact }]"
>
{{ labels[props.priority] }}
</span>
@@ -28,10 +30,18 @@ const labels: Record<TaskPriority, string> = {
padding: 0.15rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
/* 500 is the heaviest the house style goes — 400 and 500 only. */
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.compact {
padding: 1px 7px;
border-radius: 8px;
font-size: 0.7rem;
text-transform: none;
letter-spacing: normal;
}
.priority-low {
background: var(--fs-priority-low-bg);
color: var(--fs-priority-low-fg);
@@ -0,0 +1,71 @@
<script setup lang="ts">
/**
* A PROJECT's lifecycle state as a pill — active, paused, completed, archived.
*
* Deliberately not StatusBadge. That component is typed to TaskStatus and
* speaks a different vocabulary; these two only ever shared a CSS class name,
* which is what made them look like one shape that had drifted (#3132).
*
* Extracted because ProjectView and ProjectListView really were spelling the
* same pill twice, with the differences you get from two hands rather than
* two intentions: 0.68rem against 0.7rem, a 14% tint against 15%, one with a
* border and one without.
*/
const props = defineProps<{ status: string }>();
const LABELS: Record<string, string> = {
active: "Active",
paused: "Paused",
completed: "Completed",
archived: "Archived",
};
const label = (s: string) => LABELS[s] ?? s;
</script>
<template>
<span :class="['project-status', `project-status--${props.status}`]">
{{ label(props.status) }}
</span>
</template>
<style scoped>
.project-status {
font-size: 0.7rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.15rem 0.5rem;
border-radius: var(--fs-radius-pill);
flex-shrink: 0;
white-space: nowrap;
}
/* Text is the hue mixed toward --fs-text-primary, not the raw hue. Both old
spellings painted the hue on a 15% tint of itself, which measured 1.61-2.39:1
against AA's 4.5 — the same defect the status and priority ladders had, and
invisible to the token checker because the background was an inline
color-mix rather than a `-bg` token. The checker was widened alongside this.
Measured worst-case over raised and hover in both modes: active 4.82:1,
paused 4.63:1, completed 4.78:1, archived 4.84:1.
No new design tokens: four values used by one component are the kind of
growth Scribe's own design-system note warns about ("if this system grows
past a handful of tokens, that is worth noticing rather than
accommodating"). The derivation is stated once, here. */
.project-status--active {
background: color-mix(in srgb, var(--fs-success) 15%, transparent);
color: color-mix(in srgb, var(--fs-success) 45%, var(--fs-text-primary));
}
.project-status--paused {
background: color-mix(in srgb, var(--fs-warning) 15%, transparent);
color: color-mix(in srgb, var(--fs-warning) 55%, var(--fs-text-primary));
}
.project-status--completed {
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
color: color-mix(in srgb, var(--fs-accent) 45%, var(--fs-text-primary));
}
.project-status--archived {
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
color: color-mix(in srgb, var(--fs-text-tertiary) 55%, var(--fs-text-primary));
}
</style>
+14 -3
View File
@@ -4,13 +4,16 @@ import type { TaskStatus } from "@/types/task";
const props = defineProps<{
status: TaskStatus;
clickable?: boolean;
/** Dense surfaces — smaller, unshouted. The canon (#2960) names compact a
VARIANT of this component rather than a reason to re-spell it. */
compact?: boolean;
}>();
defineEmits<{ click: [] }>();
const labels: Record<TaskStatus, string> = {
todo: "Todo",
in_progress: "In Progress",
in_progress: "In progress",
done: "Done",
cancelled: "Cancelled",
};
@@ -18,7 +21,7 @@ const labels: Record<TaskStatus, string> = {
<template>
<span
:class="['status-badge', `status-${props.status}`, { clickable }]"
:class="['status-badge', `status-${props.status}`, { clickable, compact }]"
@click="clickable ? $emit('click') : undefined"
:role="clickable ? 'button' : undefined"
:tabindex="clickable ? 0 : undefined"
@@ -33,7 +36,8 @@ const labels: Record<TaskStatus, string> = {
padding: 0.15rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
/* 500 is the heaviest the house style goes — 400 and 500 only. */
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.025em;
}
@@ -57,6 +61,13 @@ const labels: Record<TaskStatus, string> = {
background: var(--fs-status-todo-bg);
color: var(--fs-status-cancelled-fg);
}
.compact {
padding: 1px 7px;
border-radius: 8px;
font-size: 0.7rem;
text-transform: none;
letter-spacing: normal;
}
.clickable {
cursor: pointer;
}
-238
View File
@@ -1,238 +0,0 @@
<script setup lang="ts">
import type { Task, TaskStatus } from "@/types/task";
import StatusBadge from "@/components/StatusBadge.vue";
import PriorityBadge from "@/components/PriorityBadge.vue";
import TagPill from "@/components/TagPill.vue";
import { relativeTime } from "@/composables/useRelativeTime";
import { renderPreview } from "@/utils/markdown";
const props = defineProps<{
task: Task;
compact?: boolean;
projectTitle?: string;
}>();
const emit = defineEmits<{
"tag-click": [tag: string];
"status-toggle": [id: number, status: TaskStatus];
}>();
const statusCycle: Record<TaskStatus, TaskStatus> = {
todo: "in_progress",
in_progress: "done",
done: "todo",
cancelled: "todo",
};
const statusDotClass: Record<TaskStatus, string> = {
todo: "dot-todo",
in_progress: "dot-in-progress",
done: "dot-done",
cancelled: "dot-cancelled",
};
const statusTitle: Record<TaskStatus, string> = {
todo: "Todo — click to mark In Progress",
in_progress: "In Progress — click to mark Done",
done: "Done — click to mark Todo",
cancelled: "Cancelled — click to mark Todo",
};
function cycleStatus() {
emit("status-toggle", props.task.id, statusCycle[props.task.status!]);
}
function isOverdue(): boolean {
if (!props.task.due_date || props.task.status === "done") return false;
const today = new Date().toISOString().slice(0, 10);
return props.task.due_date < today;
}
</script>
<template>
<router-link :to="`/tasks/${task.id}`" :class="['task-card', { compact }]">
<!-- Compact: single row -->
<template v-if="compact">
<button
:class="['status-dot', statusDotClass[task.status!]]"
:title="statusTitle[task.status!]"
@click.prevent.stop="cycleStatus"
></button>
<PriorityBadge :priority="task.priority!" />
<span class="task-title-compact">{{ task.title || "Untitled" }}</span>
<span v-if="projectTitle" class="project-crumb">{{ projectTitle }}</span>
<div class="task-tags-compact">
<TagPill
v-for="tag in task.tags?.slice(0, 2)"
:key="tag"
:tag="tag"
@click.stop="emit('tag-click', tag)"
/>
</div>
<span v-if="task.due_date" :class="['due-compact', { overdue: isOverdue() }]">
{{ task.due_date }}
</span>
</template>
<!-- Full: original layout -->
<template v-else>
<div class="task-top">
<StatusBadge
:status="task.status!"
clickable
@click.prevent.stop="cycleStatus"
/>
<PriorityBadge :priority="task.priority!" />
<h3 class="task-title">{{ task.title || "Untitled" }}</h3>
</div>
<div v-if="task.body" class="task-preview prose" v-html="renderPreview(task.body)"></div>
<div class="task-meta">
<span v-if="task.due_date" :class="['due-date', { overdue: isOverdue() }]">
Due: {{ task.due_date }}
</span>
<TagPill
v-for="tag in task.tags"
:key="tag"
:tag="tag"
@click.stop="emit('tag-click', tag)"
/>
<span class="timestamp">{{ relativeTime(task.updated_at) }}</span>
</div>
</template>
</router-link>
</template>
<style scoped>
.task-card {
display: block;
padding: 1rem;
border-radius: var(--fs-radius-lg);
text-decoration: none;
color: inherit;
background: var(--fs-surface-raised);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px color-mix(in srgb, var(--fs-accent) 6%, transparent);
transition: box-shadow 0.2s, transform 0.18s ease;
}
.task-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px color-mix(in srgb, var(--fs-accent) 14.0%, transparent);
transform: translateY(-2px);
}
/* Compact single-row layout */
.task-card.compact {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.85rem;
}
/* Status dot */
.status-dot {
flex-shrink: 0;
width: 12px;
height: 12px;
border-radius: 50%;
border: none;
cursor: pointer;
padding: 0;
transition: transform 0.1s, opacity 0.1s;
}
.status-dot:hover {
transform: scale(1.25);
opacity: 0.8;
}
.dot-todo {
background: var(--fs-status-todo);
border: 2px solid var(--fs-status-todo);
background: transparent;
border: 2px solid var(--fs-text-tertiary);
}
.dot-in-progress {
background: var(--fs-status-in-progress);
}
.dot-done {
background: var(--fs-status-done);
}
.dot-cancelled {
background: var(--fs-status-cancelled);
}
.task-title-compact {
font-size: 0.9rem;
font-weight: 500;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-crumb {
font-size: 0.75rem;
color: var(--fs-text-tertiary);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
padding: 0.1rem 0.4rem;
white-space: nowrap;
flex-shrink: 0;
}
.task-tags-compact {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.due-compact {
font-size: 0.75rem;
color: var(--fs-text-tertiary);
white-space: nowrap;
flex-shrink: 0;
}
.due-compact.overdue {
color: var(--fs-error);
font-weight: 600;
}
/* Full layout */
.task-top {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.task-title {
margin: 0;
font-size: 1.1rem;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-preview {
margin: 0 0 0.5rem;
color: var(--fs-text-secondary);
font-size: 0.9rem;
max-height: 7.5em;
overflow: hidden;
}
.task-meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.due-date {
font-size: 0.8rem;
color: var(--fs-text-secondary);
}
.due-date.overdue {
color: var(--fs-overdue);
font-weight: 600;
}
.timestamp {
margin-left: auto;
font-size: 0.75rem;
color: var(--fs-text-tertiary);
}
</style>
@@ -286,7 +286,7 @@ defineExpose({ reload: loadAll });
<div v-if="activeTask" class="task-detail">
<div class="detail-header">
<RouterLink :to="`/tasks/${activeTask.id}/edit`" target="_blank" class="btn-text btn-edit-task" title="Open full editor">Edit </RouterLink>
<span :class="['status-badge', `status-${activeTask.status}`]" @click="cycleStatus(activeTask, $event)" title="Click to cycle status">
<span :class="['status-cycler', `status-${activeTask.status}`]" @click="cycleStatus(activeTask, $event)" title="Click to cycle status">
{{ STATUS_ICON[activeTask.status] ?? "○" }} {{ activeTask.status.replace("_", " ") }}
</span>
<template v-if="deleteConfirmPending">
@@ -501,7 +501,10 @@ defineExpose({ reload: loadAll });
flex-shrink: 0;
}
.status-badge {
/* An interactive CYCLER, not a chip: it is clickable, outlined and
transparent. It shared a name with the task chip and was never the same
shape (#3132). */
.status-cycler {
padding: 0.2rem 0.55rem;
border-radius: 12px;
font-size: 0.75rem;
@@ -513,8 +516,8 @@ defineExpose({ reload: loadAll });
user-select: none;
margin-left: auto;
}
.status-badge.status-in_progress { border-color: var(--fs-accent); color: var(--fs-accent); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); }
.status-badge.status-done { border-color: var(--fs-success); color: var(--fs-success); background: color-mix(in srgb, var(--fs-success) 10%, transparent); }
.status-cycler.status-in_progress { border-color: var(--fs-accent); color: var(--fs-accent); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); }
.status-cycler.status-done { border-color: var(--fs-success); color: var(--fs-success); background: color-mix(in srgb, var(--fs-success) 10%, transparent); }
.btn-edit-task { margin-left: 0.25rem; }
.btn-edit-task:hover { text-decoration: underline; }
+8 -27
View File
@@ -2,8 +2,10 @@
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
import { useRouter } from "vue-router";
import { apiGet } from "@/api/client";
import type { TaskKind } from "@/types/note";
import type { TaskKind, TaskStatus, TaskPriority } from "@/types/note";
import KindBadge from "@/components/KindBadge.vue";
import StatusBadge from "@/components/StatusBadge.vue";
import PriorityBadge from "@/components/PriorityBadge.vue";
import GraphView from "@/views/GraphView.vue";
import {
FileText,
@@ -513,14 +515,12 @@ onUnmounted(() => {
<!-- Task specifics -->
<div v-if="item.note_type === 'task'" class="k-card-task">
<div class="task-badges">
<span class="status-badge" :class="`status--${item.status}`">
{{ item.status === 'in_progress' ? 'in progress' : item.status }}
</span>
<span
<StatusBadge v-if="item.status" :status="item.status as TaskStatus" compact />
<PriorityBadge
v-if="item.priority && item.priority !== 'none'"
class="priority-badge"
:class="`priority--${item.priority}`"
>{{ item.priority }}</span>
:priority="item.priority as TaskPriority"
compact
/>
</div>
<span
v-if="item.due_date"
@@ -940,26 +940,7 @@ onUnmounted(() => {
gap: 5px;
flex-wrap: wrap;
}
.status-badge {
font-size: 0.7rem;
padding: 1px 7px;
border-radius: 8px;
font-weight: 500;
}
.status--todo { background: var(--fs-status-todo-bg); color: var(--fs-status-todo-fg); }
.status--in_progress { background: var(--fs-status-in-progress-bg); color: var(--fs-status-in-progress-fg); }
.status--done { background: var(--fs-status-done-bg); color: var(--fs-status-done-fg); }
.status--cancelled { background: var(--fs-status-todo-bg); color: var(--fs-status-cancelled-fg); text-decoration: line-through; }
.priority-badge {
font-size: 0.7rem;
padding: 1px 7px;
border-radius: 8px;
font-weight: 500;
}
.priority--low { background: var(--fs-priority-low-bg); color: var(--fs-priority-low-fg); }
.priority--normal { background: var(--fs-priority-medium-bg); color: var(--fs-priority-medium-fg); }
.priority--high { background: var(--fs-priority-high-bg); color: var(--fs-priority-high-fg); }
.task-due {
font-size: 0.78rem;
+2 -32
View File
@@ -2,6 +2,7 @@
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { apiGet, apiPost, apiErrorMessage } from "@/api/client";
import ProjectStatusBadge from "@/components/ProjectStatusBadge.vue";
import { emptyChoices, type InceptionChoices } from "@/api/inception";
import InceptionCard from "@/components/InceptionCard.vue";
import { useToastStore } from "@/stores/toast";
@@ -109,13 +110,6 @@ async function createProject() {
}
}
function statusLabel(status: Project["status"]): string {
if (status === "active") return "Active";
if (status === "paused") return "Paused";
if (status === "completed") return "Completed";
if (status === "archived") return "Archived";
return status;
}
function truncate(text: string | null, max = 120): string {
if (!text) return "";
@@ -210,9 +204,7 @@ function overallPct(project: Project): { total: number; pct: number } {
>
<div class="card-header">
<span class="project-title">{{ project.title }}</span>
<span
:class="['status-badge', `status-${project.status}`]"
>{{ statusLabel(project.status) }}</span>
<ProjectStatusBadge :status="project.status" />
</div>
<p v-if="project.goal" class="project-goal">
<span class="field-label">Goal:</span> {{ truncate(project.goal) }}
@@ -430,28 +422,6 @@ function overallPct(project: Project): { total: number; pct: number } {
word-break: break-word;
}
.status-badge {
font-size: 0.7rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.15rem 0.45rem;
border-radius: 999px;
flex-shrink: 0;
white-space: nowrap;
}
.status-active {
background: color-mix(in srgb, var(--fs-success) 15%, transparent);
color: var(--fs-success);
}
.status-completed {
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
color: var(--fs-accent);
}
.status-archived {
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
color: var(--fs-text-tertiary);
}
.project-goal {
font-size: 0.875rem;
+2 -16
View File
@@ -9,6 +9,7 @@ import { relativeTime } from "@/composables/useRelativeTime";
import { renderMarkdown } from "@/utils/markdown";
import ShareDialog from "@/components/ShareDialog.vue";
import KindBadge from "@/components/KindBadge.vue";
import ProjectStatusBadge from "@/components/ProjectStatusBadge.vue";
import type { TaskKind } from "@/types/note";
import ProjectDesignTab from "@/components/ProjectDesignTab.vue";
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
@@ -696,9 +697,7 @@ async function confirmDelete() {
<div class="project-header">
<div class="title-row">
<input v-model="editTitle" type="text" class="project-title-input" placeholder="Project title" />
<span :class="['status-badge', `status-${project.status}`]">
{{ project.status.charAt(0).toUpperCase() + project.status.slice(1) }}
</span>
<ProjectStatusBadge :status="project.status" />
</div>
<p v-if="project.goal" class="project-goal">{{ project.goal }}</p>
<p v-if="project.summary?.last_activity" class="project-activity">
@@ -1240,19 +1239,6 @@ async function confirmDelete() {
.project-title-input:focus { border-bottom-color: var(--fs-accent); }
.project-title-input::placeholder { color: var(--fs-text-tertiary); font-weight: 400; }
.status-badge {
font-size: 0.68rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.18rem 0.55rem;
border-radius: 999px;
flex-shrink: 0;
}
.status-active { background: color-mix(in srgb, var(--fs-success) 14%, transparent); color: var(--fs-success); border: 1px solid color-mix(in srgb, var(--fs-success) 30%, transparent); }
.status-paused { background: color-mix(in srgb, var(--fs-warning) 14%, transparent); color: var(--fs-warning); border: 1px solid color-mix(in srgb, var(--fs-warning) 30%, transparent); }
.status-completed { background: color-mix(in srgb, var(--fs-accent) 14%, transparent); color: var(--fs-accent); border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent); }
.status-archived { background: color-mix(in srgb, var(--fs-text-tertiary) 14%, transparent); color: var(--fs-text-tertiary); border: 1px solid color-mix(in srgb, var(--fs-text-tertiary) 30%, transparent); }
.project-goal {
font-size: 1rem;