Tool disambiguators, kind badges, and a badge layer that clears AA (#3123, #3124, #3132) #134

Merged
bvandeusen merged 4 commits from dev into main 2026-08-27 21:28:48 -04:00
7 changed files with 134 additions and 4 deletions
Showing only changes of commit a0b54ff6a3 - Show all commits
+79
View File
@@ -0,0 +1,79 @@
<script setup lang="ts">
/**
* A task's KIND, shown on a list row — issue, spike, or a legacy plan.
*
* Sibling of PriorityBadge, and shaped like it on purpose: same geometry, and
* the same rule that the DEFAULT value renders nothing. `work` is most tasks,
* so badging it would put a chip on nearly every row and say nothing — the
* same reason RuleListPane marks only `conditional`.
*
* Kind is not status. A task can be an in-progress issue or a done spike;
* this answers "what kind of work is this", never "how is it going".
*/
import type { TaskKind } from "@/types/note";
const props = defineProps<{ kind?: TaskKind | null }>();
const LABELS: Record<string, string> = {
issue: "Issue",
spike: "Spike",
plan: "Plan",
};
const TITLES: Record<string, string> = {
issue: "Corrective work — something was broken",
spike: "Time-boxed investigation — the output is an answer, not a change",
plan: "Legacy plan-task; plans are milestones now",
};
</script>
<template>
<span
v-if="props.kind && LABELS[props.kind]"
:class="['kind-badge', `kind-${props.kind}`]"
:title="TITLES[props.kind]"
>{{ LABELS[props.kind] }}</span>
</template>
<style scoped>
.kind-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
/* 500, not the 600 StatusBadge and PriorityBadge use. The house style
allows two weights, 400 and 500 — those two predate the constraint and
copying them would spread it. */
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.025em;
white-space: nowrap;
}
/* Issue and spike are opposite in character — corrective vs exploratory — so
they are split by TEMPERATURE, warm against cool, which survives being
small and stays distinguishable without relying on reading the word.
Neither uses the accent: one accent per app, and kind is not one of the
places it is allowed.
The text is the hue mixed toward --fs-text-primary rather than the raw
semantic colour. Raw fails the contrast floor on the dark palette —
measured: warning on its own 12% tint is 2.97:1, well under AA's 4.5.
Mixing toward the text token also makes these follow the mode for free,
since that token inverts. Measured both ways: issue 5.23:1 dark / 6.68:1
light, spike 5.33:1 / 9.26:1. */
.kind-issue {
background: color-mix(in srgb, var(--fs-warning) 14%, var(--fs-surface-raised));
color: color-mix(in srgb, var(--fs-warning) 60%, var(--fs-text-primary));
}
.kind-spike {
background: color-mix(in srgb, var(--fs-info) 14%, var(--fs-surface-raised));
color: color-mix(in srgb, var(--fs-info) 50%, var(--fs-text-primary));
}
/* Retired since 0066 — deliberately hue-free so a legacy row reads as
archival rather than as a fourth active kind competing for attention. */
.kind-plan {
background: var(--fs-surface-raised);
color: var(--fs-text-tertiary);
font-style: italic;
}
</style>
@@ -4,6 +4,8 @@ import { RouterLink } from "vue-router";
import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import TaskLogSection from "@/components/TaskLogSection.vue";
import KindBadge from "@/components/KindBadge.vue";
import type { TaskKind } from "@/types/note";
import { renderMarkdown } from "@/utils/markdown";
import { Trash2, X } from "lucide-vue-next";
import { relativeTimeOrDate } from "@/composables/useRelativeTime";
@@ -28,6 +30,7 @@ interface Task {
due_date: string | null;
updated_at: string;
body?: string;
task_kind?: TaskKind;
}
const tasks = ref<Task[]>([]);
@@ -242,6 +245,7 @@ defineExpose({ reload: loadAll });
<button :class="['status-dot', `status-${task.status}`]" :title="`${task.status} — click to cycle`" @click="cycleStatus(task, $event)">{{ STATUS_ICON[task.status] ?? '' }}</button>
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
<KindBadge :kind="task.task_kind" />
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
<span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
</li>
@@ -267,6 +271,7 @@ defineExpose({ reload: loadAll });
<button :class="['status-dot', `status-${task.status}`]" :title="`${task.status} — click to cycle`" @click="cycleStatus(task, $event)">{{ STATUS_ICON[task.status] ?? '' }}</button>
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
<KindBadge :kind="task.task_kind" />
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
<span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
</li>
+5 -1
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { apiGet } from "@/api/client";
import KindBadge from "@/components/KindBadge.vue";
import type { TaskKind } from "@/types/note";
import { relativeTime } from "@/composables/useRelativeTime";
interface TaskRow { id: number; title: string; status: string; priority: string }
interface TaskRow { id: number; title: string; status: string; priority: string; task_kind?: TaskKind }
interface MilestoneBlock { id: number; title: string; progress_pct: number; open_tasks: TaskRow[] }
interface ActiveProject {
id: number; title: string; color: string | null; last_activity: string;
@@ -99,6 +101,7 @@ onMounted(async () => {
>
<span class="task-mark">{{ t.status === 'in_progress' ? '▸' : '○' }}</span>
<span class="task-title">{{ t.title }}</span>
<KindBadge :kind="t.task_kind" />
<span v-if="t.priority !== 'none'" class="task-pri" :class="`pri-${t.priority}`">{{ t.priority }}</span>
</router-link>
</div>
@@ -114,6 +117,7 @@ onMounted(async () => {
>
<span class="task-mark">{{ t.status === 'in_progress' ? '▸' : '○' }}</span>
<span class="task-title">{{ t.title }}</span>
<KindBadge :kind="t.task_kind" />
<span v-if="t.priority !== 'none'" class="task-pri" :class="`pri-${t.priority}`">{{ t.priority }}</span>
</router-link>
</div>
+9 -1
View File
@@ -2,6 +2,8 @@
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 KindBadge from "@/components/KindBadge.vue";
import GraphView from "@/views/GraphView.vue";
import {
FileText,
@@ -35,7 +37,7 @@ interface KnowledgeItem {
status?: string;
priority?: string;
due_date?: string;
task_kind?: "work" | "plan";
task_kind?: TaskKind;
}
// ─── Filter state ─────────────────────────────────────────────────────────────
@@ -498,6 +500,12 @@ onUnmounted(() => {
<span v-else-if="item.note_type === 'task'">{{ item.task_kind === 'plan' ? 'Plan' : 'Task' }}</span>
<span v-else-if="item.note_type === 'process'">Process</span>
</span>
<!-- Kind sits BESIDE the type badge, not inside it: the type badge
speaks the vocabulary of this view's type filter (note / task /
plan / process), and kind is the other axis. `plan` is passed
as null because the badge to the left already says it two
chips reading "Plan" would look like two facts. -->
<KindBadge :kind="item.task_kind === 'plan' ? null : item.task_kind" />
<div class="k-card-body">
<div class="k-card-title">{{ item.title }}</div>
+6
View File
@@ -8,6 +8,8 @@ import { useTasksStore } from "@/stores/tasks";
import { relativeTime } from "@/composables/useRelativeTime";
import { renderMarkdown } from "@/utils/markdown";
import ShareDialog from "@/components/ShareDialog.vue";
import KindBadge from "@/components/KindBadge.vue";
import type { TaskKind } from "@/types/note";
import ProjectDesignTab from "@/components/ProjectDesignTab.vue";
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
import SystemsSection from "@/components/SystemsSection.vue";
@@ -74,6 +76,7 @@ interface NoteItem {
due_date?: string | null;
updated_at: string;
milestone_id?: number | null;
task_kind?: TaskKind;
}
const route = useRoute();
@@ -1046,6 +1049,7 @@ async function confirmDelete() {
:class="['task-card', `pri-${task.priority || 'none'}`]"
>
<span class="task-title">{{ task.title || "Untitled" }}</span>
<KindBadge :kind="task.task_kind" />
<div class="task-card-footer">
<div v-if="task.priority !== 'none' || task.due_date" class="task-meta">
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', `dot-pri-${task.priority}`]" :title="task.priority"></span>
@@ -1077,6 +1081,7 @@ async function confirmDelete() {
:class="['task-card', `pri-${task.priority || 'none'}`]"
>
<span class="task-title">{{ task.title || "Untitled" }}</span>
<KindBadge :kind="task.task_kind" />
<div class="task-card-footer">
<div v-if="task.priority !== 'none' || task.due_date" class="task-meta">
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', `dot-pri-${task.priority}`]" :title="task.priority"></span>
@@ -1108,6 +1113,7 @@ async function confirmDelete() {
class="task-card task-card-done"
>
<span class="task-title">{{ task.title || "Untitled" }}</span>
<KindBadge :kind="task.task_kind" />
<div v-if="task.due_date" class="task-meta">
<span class="due-date">{{ task.due_date }}</span>
</div>
+6 -1
View File
@@ -39,8 +39,13 @@ def _open_order():
def _task_row(n: Note) -> dict:
# task_kind rides along so a dashboard row can show WHAT KIND of work it
# is, not just how it is going. Omitting it made the kind badge render
# nothing here while working everywhere else — the badge was correct and
# the payload was short, which reads as "no issues in this list" rather
# than as a missing field.
return {"id": n.id, "title": n.title, "status": n.status,
"priority": n.priority or "none"}
"priority": n.priority or "none", "task_kind": n.task_kind}
async def _safe(coro, empty):
+24 -1
View File
@@ -16,11 +16,34 @@ def test_task_row_maps_fields():
n.title = "Wire reminders"
n.status = "in_progress"
n.priority = None
# Named, not left to MagicMock: an unset attribute is a truthy Mock, so
# the assertion would pass on a field the row never really carried
# (note 2109 — the reason fake_note exists).
n.task_kind = "spike"
assert _task_row(n) == {
"id": 5, "title": "Wire reminders", "status": "in_progress", "priority": "none",
"id": 5, "title": "Wire reminders", "status": "in_progress",
"priority": "none", "task_kind": "spike",
}
def test_task_row_carries_the_kind_so_a_row_can_show_it():
"""The field whose ABSENCE is invisible.
A dashboard row with no task_kind renders no kind badge, which looks
exactly like a list containing no issues and no spikes. The badge is
correct and the payload is short — so the guard belongs on the payload,
where the omission actually was.
"""
from scribe.services.dashboard import _task_row
n = MagicMock()
n.id = 1
n.title = "t"
n.status = "todo"
n.priority = "none"
n.task_kind = "issue"
assert _task_row(n)["task_kind"] == "issue"
@pytest.mark.asyncio
async def test_safe_returns_value_then_empty_on_error():
from scribe.services.dashboard import _safe