Tool disambiguators, kind badges, and a badge layer that clears AA (#3123, #3124, #3132)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 31s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m17s
CI & Build / Build & push image (push) Successful in 15s

This commit was merged in pull request #134.
This commit is contained in:
2026-08-27 21:28:48 -04:00
23 changed files with 501 additions and 345 deletions
+14
View File
@@ -12,6 +12,13 @@
file used to read, and it is deliberate: the light palette was never specified file used to read, and it is deliberate: the light palette was never specified
by any rule, so it is recorded as a departure rather than as the default. by any rule, so it is recorded as a departure rather than as the default.
The -fg tokens are a badge's TEXT colour, added because the ladder used its
raw hue as text on a 12% tint of the same hue — measured 1.60-2.97:1 on the
dark palette against the kit's AA floor of 4.5. Each is the hue mixed toward
--fs-text-primary until it clears 4.5:1 worst-case over surface-raised and
surface-hover in BOTH modes. Mixing toward that token is what makes one
declaration cover both: it inverts, so the text follows the mode.
Only 12 tokens differ between modes. Everything else — spacing, type, motion, Only 12 tokens differ between modes. Everything else — spacing, type, motion,
radius, and every derived colour — is stated once, because a value built with radius, and every derived colour — is stated once, because a value built with
var() resolves where it is USED, not where it is written. var() resolves where it is USED, not where it is written.
@@ -78,10 +85,13 @@
/* priority */ /* priority */
--fs-priority-low: var(--fs-info); --fs-priority-low: var(--fs-info);
--fs-priority-low-bg: color-mix(in srgb, var(--fs-priority-low) 12%, transparent); --fs-priority-low-bg: color-mix(in srgb, var(--fs-priority-low) 12%, transparent);
--fs-priority-low-fg: color-mix(in srgb, var(--fs-priority-low) 45%, var(--fs-text-primary)); /* Badge TEXT for low priority — the readable partner of the -bg tint */
--fs-priority-medium: var(--fs-warning); --fs-priority-medium: var(--fs-warning);
--fs-priority-medium-bg: color-mix(in srgb, var(--fs-priority-medium) 12%, transparent); --fs-priority-medium-bg: color-mix(in srgb, var(--fs-priority-medium) 12%, transparent);
--fs-priority-medium-fg: color-mix(in srgb, var(--fs-priority-medium) 55%, var(--fs-text-primary)); /* Badge TEXT for medium priority */
--fs-priority-high: var(--fs-error); --fs-priority-high: var(--fs-error);
--fs-priority-high-bg: color-mix(in srgb, var(--fs-priority-high) 12%, transparent); --fs-priority-high-bg: color-mix(in srgb, var(--fs-priority-high) 12%, transparent);
--fs-priority-high-fg: color-mix(in srgb, var(--fs-priority-high) 55%, var(--fs-text-primary)); /* Badge TEXT for high priority */
/* radius */ /* radius */
--fs-radius-sm: 4px; /* pills, tags, code spans */ --fs-radius-sm: 4px; /* pills, tags, code spans */
@@ -116,12 +126,16 @@
/* status */ /* status */
--fs-status-todo: var(--fs-border-color); --fs-status-todo: var(--fs-border-color);
--fs-status-todo-bg: color-mix(in srgb, var(--fs-status-todo) 12%, transparent); --fs-status-todo-bg: color-mix(in srgb, var(--fs-status-todo) 12%, transparent);
--fs-status-todo-fg: color-mix(in srgb, var(--fs-status-todo) 40%, var(--fs-text-primary)); /* Badge TEXT for a not-started task */
--fs-status-in-progress: var(--fs-accent); --fs-status-in-progress: var(--fs-accent);
--fs-status-in-progress-bg: color-mix(in srgb, var(--fs-status-in-progress) 12%, transparent); --fs-status-in-progress-bg: color-mix(in srgb, var(--fs-status-in-progress) 12%, transparent);
--fs-status-in-progress-fg: color-mix(in srgb, var(--fs-status-in-progress) 45%, var(--fs-text-primary)); /* Badge TEXT for a task underway */
--fs-status-done: var(--fs-success); --fs-status-done: var(--fs-success);
--fs-status-done-bg: color-mix(in srgb, var(--fs-status-done) 12%, transparent); --fs-status-done-bg: color-mix(in srgb, var(--fs-status-done) 12%, transparent);
--fs-status-done-fg: color-mix(in srgb, var(--fs-status-done) 50%, var(--fs-text-primary)); /* Badge TEXT for a completed task */
--fs-overdue: var(--fs-error); --fs-overdue: var(--fs-error);
--fs-status-cancelled: var(--fs-text-tertiary); /* set aside, not failed */ --fs-status-cancelled: var(--fs-text-tertiary); /* set aside, not failed */
--fs-status-cancelled-fg: color-mix(in srgb, var(--fs-status-cancelled) 60%, var(--fs-text-primary)); /* Badge TEXT for a cancelled task */
/* surface */ /* surface */
--fs-surface-page: #14171A; /* page bg, deepest surface */ --fs-surface-page: #14171A; /* page bg, deepest surface */
+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>
+15 -5
View File
@@ -3,6 +3,8 @@ import type { TaskPriority } from "@/types/task";
const props = defineProps<{ const props = defineProps<{
priority: TaskPriority; priority: TaskPriority;
/** Dense surfaces — see StatusBadge. */
compact?: boolean;
}>(); }>();
const labels: Record<TaskPriority, string> = { const labels: Record<TaskPriority, string> = {
@@ -16,7 +18,7 @@ const labels: Record<TaskPriority, string> = {
<template> <template>
<span <span
v-if="props.priority !== 'none'" v-if="props.priority !== 'none'"
:class="['priority-badge', `priority-${props.priority}`]" :class="['priority-badge', `priority-${props.priority}`, { compact }]"
> >
{{ labels[props.priority] }} {{ labels[props.priority] }}
</span> </span>
@@ -28,20 +30,28 @@ const labels: Record<TaskPriority, string> = {
padding: 0.15rem 0.5rem; padding: 0.15rem 0.5rem;
border-radius: 12px; border-radius: 12px;
font-size: 0.75rem; 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; text-transform: uppercase;
letter-spacing: 0.025em; letter-spacing: 0.025em;
} }
.compact {
padding: 1px 7px;
border-radius: 8px;
font-size: 0.7rem;
text-transform: none;
letter-spacing: normal;
}
.priority-low { .priority-low {
background: var(--fs-priority-low-bg); background: var(--fs-priority-low-bg);
color: var(--fs-priority-low); color: var(--fs-priority-low-fg);
} }
.priority-medium { .priority-medium {
background: var(--fs-priority-medium-bg); background: var(--fs-priority-medium-bg);
color: var(--fs-priority-medium); color: var(--fs-priority-medium-fg);
} }
.priority-high { .priority-high {
background: var(--fs-priority-high-bg); background: var(--fs-priority-high-bg);
color: var(--fs-priority-high); color: var(--fs-priority-high-fg);
} }
</style> </style>
+3 -3
View File
@@ -153,7 +153,7 @@ watch(() => [props.projectId, props.designSystemId], run);
} }
.pdt-clean { .pdt-clean {
color: var(--fs-status-done); color: var(--fs-status-done-fg);
} }
.pdt-summary { .pdt-summary {
@@ -206,12 +206,12 @@ watch(() => [props.projectId, props.designSystemId], run);
.pdt-tag.unknown { .pdt-tag.unknown {
background: var(--fs-priority-high-bg); background: var(--fs-priority-high-bg);
color: var(--fs-priority-high); color: var(--fs-priority-high-fg);
} }
.pdt-tag.local { .pdt-tag.local {
background: var(--fs-priority-medium-bg); background: var(--fs-priority-medium-bg);
color: var(--fs-priority-medium); color: var(--fs-priority-medium-fg);
} }
.pdt-tag.superseded { .pdt-tag.superseded {
@@ -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>
+26 -11
View File
@@ -4,13 +4,16 @@ import type { TaskStatus } from "@/types/task";
const props = defineProps<{ const props = defineProps<{
status: TaskStatus; status: TaskStatus;
clickable?: boolean; 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: [] }>(); defineEmits<{ click: [] }>();
const labels: Record<TaskStatus, string> = { const labels: Record<TaskStatus, string> = {
todo: "Todo", todo: "Todo",
in_progress: "In Progress", in_progress: "In progress",
done: "Done", done: "Done",
cancelled: "Cancelled", cancelled: "Cancelled",
}; };
@@ -18,7 +21,7 @@ const labels: Record<TaskStatus, string> = {
<template> <template>
<span <span
:class="['status-badge', `status-${props.status}`, { clickable }]" :class="['status-badge', `status-${props.status}`, { clickable, compact }]"
@click="clickable ? $emit('click') : undefined" @click="clickable ? $emit('click') : undefined"
:role="clickable ? 'button' : undefined" :role="clickable ? 'button' : undefined"
:tabindex="clickable ? 0 : undefined" :tabindex="clickable ? 0 : undefined"
@@ -33,25 +36,37 @@ const labels: Record<TaskStatus, string> = {
padding: 0.15rem 0.5rem; padding: 0.15rem 0.5rem;
border-radius: 12px; border-radius: 12px;
font-size: 0.75rem; 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; text-transform: uppercase;
letter-spacing: 0.025em; letter-spacing: 0.025em;
} }
/* Text comes from the -fg tokens, which are the hue mixed toward
--fs-text-primary until they clear AA. The old spelling darkened the hue
with `#000 15%` — a light-mode instinct that made these WORSE on the dark
palette, where the surface is already near-black, and a literal besides. */
.status-todo { .status-todo {
background: color-mix(in srgb, var(--fs-status-todo-bg) 78%, var(--fs-status-todo) 22%); background: var(--fs-status-todo-bg);
color: color-mix(in srgb, var(--fs-status-todo) 85%, #000 15%); color: var(--fs-status-todo-fg);
} }
.status-in_progress { .status-in_progress {
background: color-mix(in srgb, var(--fs-status-in-progress-bg) 78%, var(--fs-status-in-progress) 22%); background: var(--fs-status-in-progress-bg);
color: color-mix(in srgb, var(--fs-status-in-progress) 85%, #000 15%); color: var(--fs-status-in-progress-fg);
} }
.status-done { .status-done {
background: color-mix(in srgb, var(--fs-status-done-bg) 78%, var(--fs-status-done) 22%); background: var(--fs-status-done-bg);
color: color-mix(in srgb, var(--fs-status-done) 85%, #000 15%); color: var(--fs-status-done-fg);
} }
.status-cancelled { .status-cancelled {
background: color-mix(in srgb, var(--fs-surface-raised) 78%, var(--fs-text-tertiary) 22%); background: var(--fs-status-todo-bg);
color: var(--fs-text-tertiary); color: var(--fs-status-cancelled-fg);
}
.compact {
padding: 1px 7px;
border-radius: 8px;
font-size: 0.7rem;
text-transform: none;
letter-spacing: normal;
} }
.clickable { .clickable {
cursor: pointer; cursor: pointer;
+2 -2
View File
@@ -554,8 +554,8 @@ async function confirmDelete() {
/* The two bases must never look alike — one is mechanical, the other is the /* The two bases must never look alike — one is mechanical, the other is the
reviewer's judgment, and that difference is the whole decision. */ reviewer's judgment, and that difference is the whole decision. */
.area-basis { font-size: 0.68rem; border-radius: var(--fs-radius-sm); padding: 0.05rem 0.4rem; } .area-basis { font-size: 0.68rem; border-radius: var(--fs-radius-sm); padding: 0.05rem 0.4rem; }
.area-basis--exact { background: var(--fs-status-done-bg); color: var(--fs-status-done); } .area-basis--exact { background: var(--fs-status-done-bg); color: var(--fs-status-done-fg); }
.area-basis--overlap { background: var(--fs-priority-medium-bg); color: var(--fs-priority-medium); } .area-basis--overlap { background: var(--fs-priority-medium-bg); color: var(--fs-priority-medium-fg); }
.area-offer { .area-offer {
display: flex; display: flex;
-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>
+12 -4
View File
@@ -4,6 +4,8 @@ import { RouterLink } from "vue-router";
import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client"; import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client";
import { useToastStore } from "@/stores/toast"; import { useToastStore } from "@/stores/toast";
import TaskLogSection from "@/components/TaskLogSection.vue"; import TaskLogSection from "@/components/TaskLogSection.vue";
import KindBadge from "@/components/KindBadge.vue";
import type { TaskKind } from "@/types/note";
import { renderMarkdown } from "@/utils/markdown"; import { renderMarkdown } from "@/utils/markdown";
import { Trash2, X } from "lucide-vue-next"; import { Trash2, X } from "lucide-vue-next";
import { relativeTimeOrDate } from "@/composables/useRelativeTime"; import { relativeTimeOrDate } from "@/composables/useRelativeTime";
@@ -28,6 +30,7 @@ interface Task {
due_date: string | null; due_date: string | null;
updated_at: string; updated_at: string;
body?: string; body?: string;
task_kind?: TaskKind;
} }
const tasks = ref<Task[]>([]); 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> <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 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> <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 v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
<span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span> <span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
</li> </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> <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 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> <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 v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
<span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span> <span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
</li> </li>
@@ -281,7 +286,7 @@ defineExpose({ reload: loadAll });
<div v-if="activeTask" class="task-detail"> <div v-if="activeTask" class="task-detail">
<div class="detail-header"> <div class="detail-header">
<RouterLink :to="`/tasks/${activeTask.id}/edit`" target="_blank" class="btn-text btn-edit-task" title="Open full editor">Edit </RouterLink> <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("_", " ") }} {{ STATUS_ICON[activeTask.status] ?? "○" }} {{ activeTask.status.replace("_", " ") }}
</span> </span>
<template v-if="deleteConfirmPending"> <template v-if="deleteConfirmPending">
@@ -496,7 +501,10 @@ defineExpose({ reload: loadAll });
flex-shrink: 0; 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; padding: 0.2rem 0.55rem;
border-radius: 12px; border-radius: 12px;
font-size: 0.75rem; font-size: 0.75rem;
@@ -508,8 +516,8 @@ defineExpose({ reload: loadAll });
user-select: none; user-select: none;
margin-left: auto; 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-cycler.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-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 { margin-left: 0.25rem; }
.btn-edit-task:hover { text-decoration: underline; } .btn-edit-task:hover { text-decoration: underline; }
+5 -1
View File
@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from "vue"; import { ref, onMounted } from "vue";
import { apiGet } from "@/api/client"; import { apiGet } from "@/api/client";
import KindBadge from "@/components/KindBadge.vue";
import type { TaskKind } from "@/types/note";
import { relativeTime } from "@/composables/useRelativeTime"; 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 MilestoneBlock { id: number; title: string; progress_pct: number; open_tasks: TaskRow[] }
interface ActiveProject { interface ActiveProject {
id: number; title: string; color: string | null; last_activity: string; 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-mark">{{ t.status === 'in_progress' ? '▸' : '○' }}</span>
<span class="task-title">{{ t.title }}</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> <span v-if="t.priority !== 'none'" class="task-pri" :class="`pri-${t.priority}`">{{ t.priority }}</span>
</router-link> </router-link>
</div> </div>
@@ -114,6 +117,7 @@ onMounted(async () => {
> >
<span class="task-mark">{{ t.status === 'in_progress' ? '▸' : '○' }}</span> <span class="task-mark">{{ t.status === 'in_progress' ? '▸' : '○' }}</span>
<span class="task-title">{{ t.title }}</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> <span v-if="t.priority !== 'none'" class="task-pri" :class="`pri-${t.priority}`">{{ t.priority }}</span>
</router-link> </router-link>
</div> </div>
+2 -2
View File
@@ -1427,12 +1427,12 @@ textarea.input {
.spec-status.violated { .spec-status.violated {
background: var(--fs-priority-high-bg); background: var(--fs-priority-high-bg);
color: var(--fs-priority-high); color: var(--fs-priority-high-fg);
} }
.spec-status.missing { .spec-status.missing {
background: var(--fs-priority-medium-bg); background: var(--fs-priority-medium-bg);
color: var(--fs-priority-medium); color: var(--fs-priority-medium-fg);
} }
.sheet { .sheet {
+16 -27
View File
@@ -2,6 +2,10 @@
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue"; import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { apiGet } from "@/api/client"; import { apiGet } from "@/api/client";
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 GraphView from "@/views/GraphView.vue";
import { import {
FileText, FileText,
@@ -35,7 +39,7 @@ interface KnowledgeItem {
status?: string; status?: string;
priority?: string; priority?: string;
due_date?: string; due_date?: string;
task_kind?: "work" | "plan"; task_kind?: TaskKind;
} }
// ─── Filter state ───────────────────────────────────────────────────────────── // ─── Filter state ─────────────────────────────────────────────────────────────
@@ -498,6 +502,12 @@ onUnmounted(() => {
<span v-else-if="item.note_type === 'task'">{{ item.task_kind === 'plan' ? 'Plan' : 'Task' }}</span> <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 v-else-if="item.note_type === 'process'">Process</span>
</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-body">
<div class="k-card-title">{{ item.title }}</div> <div class="k-card-title">{{ item.title }}</div>
@@ -505,14 +515,12 @@ onUnmounted(() => {
<!-- Task specifics --> <!-- Task specifics -->
<div v-if="item.note_type === 'task'" class="k-card-task"> <div v-if="item.note_type === 'task'" class="k-card-task">
<div class="task-badges"> <div class="task-badges">
<span class="status-badge" :class="`status--${item.status}`"> <StatusBadge v-if="item.status" :status="item.status as TaskStatus" compact />
{{ item.status === 'in_progress' ? 'in progress' : item.status }} <PriorityBadge
</span>
<span
v-if="item.priority && item.priority !== 'none'" v-if="item.priority && item.priority !== 'none'"
class="priority-badge" :priority="item.priority as TaskPriority"
:class="`priority--${item.priority}`" compact
>{{ item.priority }}</span> />
</div> </div>
<span <span
v-if="item.due_date" v-if="item.due_date"
@@ -932,26 +940,7 @@ onUnmounted(() => {
gap: 5px; gap: 5px;
flex-wrap: wrap; 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); }
.status--in_progress { background: var(--fs-status-in-progress-bg); color: var(--fs-status-in-progress); }
.status--done { background: var(--fs-status-done-bg); color: var(--fs-status-done); }
.status--cancelled { background: var(--fs-status-todo-bg); color: var(--fs-status-todo); 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); }
.priority--normal { background: var(--fs-priority-medium-bg); color: var(--fs-priority-medium); }
.priority--high { background: var(--fs-priority-high-bg); color: var(--fs-priority-high); }
.task-due { .task-due {
font-size: 0.78rem; font-size: 0.78rem;
+2 -32
View File
@@ -2,6 +2,7 @@
import { ref, computed, onMounted } from "vue"; import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { apiGet, apiPost, apiErrorMessage } from "@/api/client"; import { apiGet, apiPost, apiErrorMessage } from "@/api/client";
import ProjectStatusBadge from "@/components/ProjectStatusBadge.vue";
import { emptyChoices, type InceptionChoices } from "@/api/inception"; import { emptyChoices, type InceptionChoices } from "@/api/inception";
import InceptionCard from "@/components/InceptionCard.vue"; import InceptionCard from "@/components/InceptionCard.vue";
import { useToastStore } from "@/stores/toast"; 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 { function truncate(text: string | null, max = 120): string {
if (!text) return ""; if (!text) return "";
@@ -210,9 +204,7 @@ function overallPct(project: Project): { total: number; pct: number } {
> >
<div class="card-header"> <div class="card-header">
<span class="project-title">{{ project.title }}</span> <span class="project-title">{{ project.title }}</span>
<span <ProjectStatusBadge :status="project.status" />
:class="['status-badge', `status-${project.status}`]"
>{{ statusLabel(project.status) }}</span>
</div> </div>
<p v-if="project.goal" class="project-goal"> <p v-if="project.goal" class="project-goal">
<span class="field-label">Goal:</span> {{ truncate(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; 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 { .project-goal {
font-size: 0.875rem; font-size: 0.875rem;
+8 -16
View File
@@ -8,6 +8,9 @@ import { useTasksStore } from "@/stores/tasks";
import { relativeTime } from "@/composables/useRelativeTime"; import { relativeTime } from "@/composables/useRelativeTime";
import { renderMarkdown } from "@/utils/markdown"; import { renderMarkdown } from "@/utils/markdown";
import ShareDialog from "@/components/ShareDialog.vue"; 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 ProjectDesignTab from "@/components/ProjectDesignTab.vue";
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue"; import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
import SystemsSection from "@/components/SystemsSection.vue"; import SystemsSection from "@/components/SystemsSection.vue";
@@ -74,6 +77,7 @@ interface NoteItem {
due_date?: string | null; due_date?: string | null;
updated_at: string; updated_at: string;
milestone_id?: number | null; milestone_id?: number | null;
task_kind?: TaskKind;
} }
const route = useRoute(); const route = useRoute();
@@ -693,9 +697,7 @@ async function confirmDelete() {
<div class="project-header"> <div class="project-header">
<div class="title-row"> <div class="title-row">
<input v-model="editTitle" type="text" class="project-title-input" placeholder="Project title" /> <input v-model="editTitle" type="text" class="project-title-input" placeholder="Project title" />
<span :class="['status-badge', `status-${project.status}`]"> <ProjectStatusBadge :status="project.status" />
{{ project.status.charAt(0).toUpperCase() + project.status.slice(1) }}
</span>
</div> </div>
<p v-if="project.goal" class="project-goal">{{ project.goal }}</p> <p v-if="project.goal" class="project-goal">{{ project.goal }}</p>
<p v-if="project.summary?.last_activity" class="project-activity"> <p v-if="project.summary?.last_activity" class="project-activity">
@@ -1046,6 +1048,7 @@ async function confirmDelete() {
:class="['task-card', `pri-${task.priority || 'none'}`]" :class="['task-card', `pri-${task.priority || 'none'}`]"
> >
<span class="task-title">{{ task.title || "Untitled" }}</span> <span class="task-title">{{ task.title || "Untitled" }}</span>
<KindBadge :kind="task.task_kind" />
<div class="task-card-footer"> <div class="task-card-footer">
<div v-if="task.priority !== 'none' || task.due_date" class="task-meta"> <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> <span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', `dot-pri-${task.priority}`]" :title="task.priority"></span>
@@ -1077,6 +1080,7 @@ async function confirmDelete() {
:class="['task-card', `pri-${task.priority || 'none'}`]" :class="['task-card', `pri-${task.priority || 'none'}`]"
> >
<span class="task-title">{{ task.title || "Untitled" }}</span> <span class="task-title">{{ task.title || "Untitled" }}</span>
<KindBadge :kind="task.task_kind" />
<div class="task-card-footer"> <div class="task-card-footer">
<div v-if="task.priority !== 'none' || task.due_date" class="task-meta"> <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> <span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', `dot-pri-${task.priority}`]" :title="task.priority"></span>
@@ -1108,6 +1112,7 @@ async function confirmDelete() {
class="task-card task-card-done" class="task-card task-card-done"
> >
<span class="task-title">{{ task.title || "Untitled" }}</span> <span class="task-title">{{ task.title || "Untitled" }}</span>
<KindBadge :kind="task.task_kind" />
<div v-if="task.due_date" class="task-meta"> <div v-if="task.due_date" class="task-meta">
<span class="due-date">{{ task.due_date }}</span> <span class="due-date">{{ task.due_date }}</span>
</div> </div>
@@ -1234,19 +1239,6 @@ async function confirmDelete() {
.project-title-input:focus { border-bottom-color: var(--fs-accent); } .project-title-input:focus { border-bottom-color: var(--fs-accent); }
.project-title-input::placeholder { color: var(--fs-text-tertiary); font-weight: 400; } .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 { .project-goal {
font-size: 1rem; font-size: 1rem;
+80 -1
View File
@@ -90,6 +90,54 @@ def style_source(path: pathlib.Path) -> str:
return CSS_COMMENT.sub(" ", css) return CSS_COMMENT.sub(" ", css)
# A rule that paints text with a colour token AND its own -bg tint of the same
# token. The pair looks harmonious and is close to illegible: a 12% tint of a
# hue sits near the surface, so the hue as text on it lands around 2:1 against
# an AA floor of 4.5. Measured across the whole Scribe ladder in 2026-08:
# every one of the six pairs failed on the dark palette, worst 1.60:1.
#
# The fix is always the same and always available — the token's `-fg` sibling,
# which is the hue mixed toward --fs-text-primary far enough to clear AA. So
# this FAILS rather than reports: unlike a raw literal, there is nothing to
# weigh up.
SAME_TOKEN_PAIR = re.compile(
r"color\s*:\s*var\(\s*(--fs-[\w-]+?)\s*\)" # color: var(--fs-X)
r"|background(?:-color)?\s*:\s*var\(\s*(--fs-[\w-]+?)-bg\s*\)"
)
def same_hue_text_on_tint(css: str) -> tuple[list[str], list[str]]:
"""Tokens used as TEXT on a tint of themselves, within one rule block.
TWO SPELLINGS of the same background, because the first version of this
check only knew the first and missed four live instances:
background: var(--fs-X-bg) the token
background: color-mix(in srgb, var(--fs-X) N%, transparent) inline
The inline form is what the project-status pills used, and it is the more
dangerous of the two — it does not even name a `-bg` token, so nothing
about it looks like the pattern until you measure it.
Returned separately because they are at different stages. The token form
is CLEAN and therefore gates. The inline form has a live backlog (48 sites
when this split was written, 26 of them --fs-accent), so it reports with a
count: a gate nobody can satisfy today gets switched off, and then it
guards nothing.
"""
token_form, inline_form = [], []
for body in re.findall(r"\{([^{}]*)\}", css):
fg = set(re.findall(r"color\s*:\s*var\(\s*(--fs-[\w-]+?)\s*\)", body))
bg_tok = set(re.findall(r"background(?:-color)?\s*:\s*var\(\s*(--fs-[\w-]+?)-bg\s*\)", body))
bg_inl = set(re.findall(
r"background(?:-color)?\s*:\s*color-mix\([^;]*?var\(\s*(--fs-[\w-]+?)\s*\)[^;]*?\)",
body,
))
token_form.extend(sorted(fg & bg_tok))
inline_form.extend(sorted(fg & (bg_inl - bg_tok)))
return token_form, inline_form
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--sheet", default="frontend/src/assets/theme.css") parser.add_argument("--sheet", default="frontend/src/assets/theme.css")
@@ -117,6 +165,8 @@ def main() -> int:
) )
unresolved: list[tuple[pathlib.Path, str]] = [] unresolved: list[tuple[pathlib.Path, str]] = []
same_hue_hits: list[tuple[pathlib.Path, str]] = []
inline_tint_hits: list[tuple[pathlib.Path, str]] = []
superseded_hits: list[tuple[pathlib.Path, str, str]] = [] superseded_hits: list[tuple[pathlib.Path, str, str]] = []
literal_count = 0 literal_count = 0
@@ -140,6 +190,12 @@ def main() -> int:
literal_count += len(HEX_LITERAL.findall(css)) literal_count += len(HEX_LITERAL.findall(css))
tok_hits, inl_hits = same_hue_text_on_tint(css)
for tok in tok_hits:
same_hue_hits.append((path, tok))
for tok in inl_hits:
inline_tint_hits.append((path, tok))
if unresolved: if unresolved:
print(f"FAIL — {len(unresolved)} unresolvable var() reference(s).") print(f"FAIL — {len(unresolved)} unresolvable var() reference(s).")
print(" These render as the fallback if given one, or as nothing at all.") print(" These render as the fallback if given one, or as nothing at all.")
@@ -162,12 +218,35 @@ def main() -> int:
print(f" {path}: {literal} -> {token}") print(f" {path}: {literal} -> {token}")
print() print()
if same_hue_hits:
print(f"FAIL — {len(same_hue_hits)} rule(s) paint text with a token on a "
f"tint of that same token.")
print(" A 12% tint sits near the surface, so the hue as text on it lands "
"around 2:1 against AA's 4.5.")
print(" Use the token's -fg sibling, which is mixed toward "
"--fs-text-primary until it clears the floor.\n")
for path, tok in same_hue_hits:
print(f" {path}: color: var({tok}) on var({tok}-bg) -> var({tok}-fg)")
print()
else:
print("OK — no text painted with a token on a tint of its own -bg.\n")
if inline_tint_hits:
by_tok: dict[str, int] = {}
for _p, tok in inline_tint_hits:
by_tok[tok] = by_tok.get(tok, 0) + 1
print(f"REPORT — {len(inline_tint_hits)} rule(s) paint text with a token on "
f"an INLINE color-mix tint of that same token.")
print(" Same defect, spelled without a -bg token so it does not gate yet.")
print(" Worst offenders: " + ", ".join(
f"{t} x{n}" for t, n in sorted(by_tok.items(), key=lambda kv: -kv[1])[:4]) + "\n")
if args.report_literals: if args.report_literals:
print(f"REPORT — {literal_count} raw colour literal(s) in component CSS.") print(f"REPORT — {literal_count} raw colour literal(s) in component CSS.")
print(" Advisory: a literal is a value stated outside the system, so it " print(" Advisory: a literal is a value stated outside the system, so it "
"cannot follow a palette change.\n") "cannot follow a palette change.\n")
return 1 if unresolved else 0 return 1 if (unresolved or same_hue_hits) else 0
if __name__ == "__main__": if __name__ == "__main__":
+11
View File
@@ -107,6 +107,17 @@ async def create_note(
) -> dict: ) -> dict:
"""Create a new note in Scribe. """Create a new note in Scribe.
WHAT ELSE COULD HOLD THIS? A note is the right home when the answer is
"nothing": it records what you know, nobody owes anything on it, and
nothing enforces it. Otherwise —
- someone has to DO something -> create_task. A note titled "we should…"
is a task nobody will ever see again.
- future sessions must OBEY it -> create_rule. The test is whether
ignoring it would be a mistake, not merely uninformed.
- reusable code with a place in a repo -> create_snippet. The location is
what lets it be found from the file someone is about to edit.
- a procedure followed start to finish -> create_process.
Args: Args:
title: Note title (required). title: Note title (required).
body: Markdown content. Supports [[wikilinks]] to other notes by title. body: Markdown content. Supports [[wikilinks]] to other notes by title.
+6
View File
@@ -54,6 +54,12 @@ async def create_process(
) -> dict: ) -> dict:
"""Create a stored process (a reusable saved prompt). """Create a stored process (a reusable saved prompt).
FOLLOWED, OR READ? A process is invoked deliberately and worked through
start to finish. If it should apply whether or not anyone invokes it, it
is a rule (create_rule) — that is the whole difference between a procedure
and a standing instruction. If it is knowledge to consult rather than
steps to execute, it is a note (create_note).
AUTHOR IT AS A SHAPE, NOT A SCRIPT. A process's value is the accumulated AUTHOR IT AS A SHAPE, NOT A SCRIPT. A process's value is the accumulated
procedure — the steps, the taxonomy, the quality bar, the failure modes procedure — the steps, the taxonomy, the quality bar, the failure modes
worth guarding. It must not force anything the invoking conversation worth guarding. It must not force anything the invoking conversation
+7
View File
@@ -407,6 +407,13 @@ async def create_project_rule(
the rule is returned in get_project's applicable_rules (under the rule is returned in get_project's applicable_rules (under
project_rules) and in list_rules(project_id=...). project_rules) and in list_rules(project_id=...).
Check first whether a rule is the right shape at all — create_rule's
opening asks that question and it applies identically here. A visual
standard is a design system; a procedure is a process (create_process);
reusable code is a snippet (create_snippet). Each of those is structure a
tool can resolve, render and check, where a rule is only prose someone
has to remember and apply.
ONE RULE = ONE THING YOU COULD VIOLATE — see create_rule. A rule that ONE RULE = ONE THING YOU COULD VIOLATE — see create_rule. A rule that
STRICTENS or REPLACES an inherited one is not a fresh rule: write it, then STRICTENS or REPLACES an inherited one is not a fresh rule: write it, then
relate_rules(kind="overrides") to the rule it supersedes, so the pair stays relate_rules(kind="overrides") to the rule it supersedes, so the pair stays
+7
View File
@@ -115,6 +115,13 @@ async def create_snippet(
"""Record a shape in the project's pattern library, so every later """Record a shape in the project's pattern library, so every later
instance starts from it instead of re-deriving it. instance starts from it instead of re-deriving it.
IS THE SHAPE THE POINT, OR THE ADVICE? A snippet is code with a LOCATION —
that is what lets it surface from the file someone is about to edit. If
what wants recording is a standing instruction about how to work, it is a
rule (create_rule); a procedure followed start to finish is a process
(create_process); what you LEARNED rather than what to copy is a note
(create_note).
Reach for this the FIRST time any shape is built — a component, a control, Reach for this the FIRST time any shape is built — a component, a control,
a route handler, a service class, a helper, a test scaffold — not only a route handler, a service class, a helper, a test scaffold — not only
when something is judged "reusable": the builder of the first instance when something is judged "reusable": the builder of the first instance
+10 -1
View File
@@ -135,6 +135,13 @@ async def create_task(
) -> dict: ) -> dict:
"""Create a new task in Scribe. """Create a new task in Scribe.
IS ANYTHING ACTUALLY OWED? A task carries a status and someone is on the
hook to move it. If nothing is owed — you are recording what you learned,
decided or observed — that is a note (create_note), and filing it here
leaves a to-do nobody will ever close. If the work is an ARC of several
steps toward one goal, start_planning makes the milestone that holds
them; a task is one step, not the plan.
Args: Args:
title: Task title (required). title: Task title (required).
body: Markdown description / notes for the task. body: Markdown description / notes for the task.
@@ -323,7 +330,9 @@ async def start_planning(project_id: int, title: str) -> dict:
Reach for this when the work has an ARC — several steps toward one goal, Reach for this when the work has an ARC — several steps toward one goal,
worth tracking as a unit. Work without one (a fix, a one-file change, a worth tracking as a unit. Work without one (a fix, a one-file change, a
question answered) is a task, not a plan: create_task, drive its status, and question answered) is a task, not a plan: create_task, drive its status, and
record progress with add_task_log. A milestone holding a single step is record progress with add_task_log. A design or decision you are RECORDING
rather than executing is a note (create_note) — a plan nobody is going to
work through is a document filed in the place reserved for open work. A milestone holding a single step is
ceremony, and it leaves the project with a plan that never meant anything. ceremony, and it leaves the project with a plan that never meant anything.
Creates a MILESTONE that IS the plan: its `body` is seeded with a design Creates a MILESTONE that IS the plan: its `body` is seeded with a design
+6 -1
View File
@@ -39,8 +39,13 @@ def _open_order():
def _task_row(n: Note) -> dict: 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, 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): async def _safe(coro, empty):
+95
View File
@@ -0,0 +1,95 @@
"""Every create_* tool says what it is NOT for (#3123).
WHY THIS EXISTS
Scribe's record kinds are reached for interchangeably — a note written where
a task was owed, a rule written where a snippet belonged — and the moment of
choice is the only moment a correction is cheap. The tool docstring IS the
agent-facing contract (rule 119 puts product guidance there and nowhere
else), so a docstring that only documents parameters answers "how do I call
this" while leaving "should I be calling this at all" unasked.
The gap was lopsided before this: `create_rule` and `start_planning` both
carried a real disambiguator, and `create_note` — far and away the
highest-volume surface — carried none. Guidance sat in the rarest tool and
was missing from the most common one.
WHAT THIS PINS, AND WHAT IT DOES NOT
It asserts STRUCTURE, never wording: each create surface must name at least
two sibling surfaces, so a caller who reached for the wrong one is told
where the right one is. Prose stays free to be rewritten — pinning phrasing
would make every improvement a test failure, and a test that punishes
editing is a test that gets deleted.
It cannot tell whether the guidance is any GOOD. It only catches the
regression that actually happens: a docstring rewritten down to its
parameters, with the "is this even the right tool" paragraph quietly gone.
"""
import re
import pytest
# The create surfaces and where they live. `start_planning` is here because
# it is a create in everything but name — it is how a plan comes into being.
_SURFACES = [
("scribe.mcp.tools.notes", "create_note"),
("scribe.mcp.tools.tasks", "create_task"),
("scribe.mcp.tools.tasks", "start_planning"),
("scribe.mcp.tools.snippets", "create_snippet"),
("scribe.mcp.tools.processes", "create_process"),
("scribe.mcp.tools.rulebooks", "create_rule"),
("scribe.mcp.tools.rulebooks", "create_project_rule"),
]
# What a caller could have wanted instead. A surface naming two of these has
# pointed somewhere; naming none has left them where they were.
_ALTERNATIVES = [
"create_note", "create_task", "create_rule", "create_project_rule",
"create_snippet", "create_process", "start_planning", "design system",
]
def _doc(module: str, name: str) -> str:
"""The docstring with its whitespace flattened.
Flattened because a docstring is hard-wrapped: "design system" spans a
line break in at least one of these, and matching the raw text would
report it absent. The first draft of this check did exactly that, and
caught it on itself.
"""
import importlib
fn = getattr(importlib.import_module(module), name)
assert fn.__doc__, f"{name} has no docstring at all"
return re.sub(r"\s+", " ", fn.__doc__)
@pytest.mark.parametrize(("module", "name"), _SURFACES)
def test_a_create_surface_names_at_least_two_alternatives(module, name):
"""Reaching for the wrong tool must still put the right one in view."""
doc = _doc(module, name)
named = {
alt for alt in _ALTERNATIVES
if alt != name and re.search(re.escape(alt), doc, re.IGNORECASE)
}
assert len(named) >= 2, (
f"{name}'s docstring names {sorted(named) or 'no'} alternative "
f"surface(s). A caller who reached for it by mistake gets no "
f"correction. Say what belongs elsewhere and why — see create_rule "
f"or create_note for the shape."
)
@pytest.mark.parametrize(("module", "name"), _SURFACES)
def test_a_create_surface_still_documents_its_arguments(module, name):
"""The guard above must not be satisfiable by deleting the parameter docs.
Both halves matter and they pull in opposite directions: a docstring can
be made to pass the disambiguator check by becoming an essay about the
other tools, which would be a worse contract than the one being fixed.
"""
assert "Args:" in _doc(module, name), (
f"{name}'s docstring lost its Args: block — the parameter contract is "
f"what a caller reads to make the call at all."
)
+24 -1
View File
@@ -16,11 +16,34 @@ def test_task_row_maps_fields():
n.title = "Wire reminders" n.title = "Wire reminders"
n.status = "in_progress" n.status = "in_progress"
n.priority = None 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) == { 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 @pytest.mark.asyncio
async def test_safe_returns_value_then_empty_on_error(): async def test_safe_returns_value_then_empty_on_error():
from scribe.services.dashboard import _safe from scribe.services.dashboard import _safe