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
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:
@@ -12,6 +12,13 @@
|
||||
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.
|
||||
|
||||
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,
|
||||
radius, and every derived colour — is stated once, because a value built with
|
||||
var() resolves where it is USED, not where it is written.
|
||||
@@ -78,10 +85,13 @@
|
||||
/* priority */
|
||||
--fs-priority-low: var(--fs-info);
|
||||
--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-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-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 */
|
||||
--fs-radius-sm: 4px; /* pills, tags, code spans */
|
||||
@@ -116,12 +126,16 @@
|
||||
/* status */
|
||||
--fs-status-todo: var(--fs-border-color);
|
||||
--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-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-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-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 */
|
||||
--fs-surface-page: #14171A; /* page bg, deepest surface */
|
||||
|
||||
@@ -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>
|
||||
@@ -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,20 +30,28 @@ 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);
|
||||
color: var(--fs-priority-low-fg);
|
||||
}
|
||||
.priority-medium {
|
||||
background: var(--fs-priority-medium-bg);
|
||||
color: var(--fs-priority-medium);
|
||||
color: var(--fs-priority-medium-fg);
|
||||
}
|
||||
.priority-high {
|
||||
background: var(--fs-priority-high-bg);
|
||||
color: var(--fs-priority-high);
|
||||
color: var(--fs-priority-high-fg);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -153,7 +153,7 @@ watch(() => [props.projectId, props.designSystemId], run);
|
||||
}
|
||||
|
||||
.pdt-clean {
|
||||
color: var(--fs-status-done);
|
||||
color: var(--fs-status-done-fg);
|
||||
}
|
||||
|
||||
.pdt-summary {
|
||||
@@ -206,12 +206,12 @@ watch(() => [props.projectId, props.designSystemId], run);
|
||||
|
||||
.pdt-tag.unknown {
|
||||
background: var(--fs-priority-high-bg);
|
||||
color: var(--fs-priority-high);
|
||||
color: var(--fs-priority-high-fg);
|
||||
}
|
||||
|
||||
.pdt-tag.local {
|
||||
background: var(--fs-priority-medium-bg);
|
||||
color: var(--fs-priority-medium);
|
||||
color: var(--fs-priority-medium-fg);
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -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,25 +36,37 @@ 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;
|
||||
}
|
||||
/* 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 {
|
||||
background: color-mix(in srgb, var(--fs-status-todo-bg) 78%, var(--fs-status-todo) 22%);
|
||||
color: color-mix(in srgb, var(--fs-status-todo) 85%, #000 15%);
|
||||
background: var(--fs-status-todo-bg);
|
||||
color: var(--fs-status-todo-fg);
|
||||
}
|
||||
.status-in_progress {
|
||||
background: color-mix(in srgb, var(--fs-status-in-progress-bg) 78%, var(--fs-status-in-progress) 22%);
|
||||
color: color-mix(in srgb, var(--fs-status-in-progress) 85%, #000 15%);
|
||||
background: var(--fs-status-in-progress-bg);
|
||||
color: var(--fs-status-in-progress-fg);
|
||||
}
|
||||
.status-done {
|
||||
background: color-mix(in srgb, var(--fs-status-done-bg) 78%, var(--fs-status-done) 22%);
|
||||
color: color-mix(in srgb, var(--fs-status-done) 85%, #000 15%);
|
||||
background: var(--fs-status-done-bg);
|
||||
color: var(--fs-status-done-fg);
|
||||
}
|
||||
.status-cancelled {
|
||||
background: color-mix(in srgb, var(--fs-surface-raised) 78%, var(--fs-text-tertiary) 22%);
|
||||
color: var(--fs-text-tertiary);
|
||||
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;
|
||||
|
||||
@@ -554,8 +554,8 @@ async function confirmDelete() {
|
||||
/* The two bases must never look alike — one is mechanical, the other is the
|
||||
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--exact { background: var(--fs-status-done-bg); color: var(--fs-status-done); }
|
||||
.area-basis--overlap { background: var(--fs-priority-medium-bg); color: var(--fs-priority-medium); }
|
||||
.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-fg); }
|
||||
|
||||
.area-offer {
|
||||
display: flex;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -281,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">
|
||||
@@ -496,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;
|
||||
@@ -508,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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1427,12 +1427,12 @@ textarea.input {
|
||||
|
||||
.spec-status.violated {
|
||||
background: var(--fs-priority-high-bg);
|
||||
color: var(--fs-priority-high);
|
||||
color: var(--fs-priority-high-fg);
|
||||
}
|
||||
|
||||
.spec-status.missing {
|
||||
background: var(--fs-priority-medium-bg);
|
||||
color: var(--fs-priority-medium);
|
||||
color: var(--fs-priority-medium-fg);
|
||||
}
|
||||
|
||||
.sheet {
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
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 {
|
||||
FileText,
|
||||
@@ -35,7 +39,7 @@ interface KnowledgeItem {
|
||||
status?: string;
|
||||
priority?: string;
|
||||
due_date?: string;
|
||||
task_kind?: "work" | "plan";
|
||||
task_kind?: TaskKind;
|
||||
}
|
||||
|
||||
// ─── 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 === '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>
|
||||
@@ -505,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"
|
||||
@@ -932,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); }
|
||||
.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 {
|
||||
font-size: 0.78rem;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -8,6 +8,9 @@ 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 ProjectStatusBadge from "@/components/ProjectStatusBadge.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 +77,7 @@ interface NoteItem {
|
||||
due_date?: string | null;
|
||||
updated_at: string;
|
||||
milestone_id?: number | null;
|
||||
task_kind?: TaskKind;
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
@@ -693,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">
|
||||
@@ -1046,6 +1048,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 +1080,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 +1112,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>
|
||||
@@ -1234,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;
|
||||
|
||||
@@ -90,6 +90,54 @@ def style_source(path: pathlib.Path) -> str:
|
||||
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:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--sheet", default="frontend/src/assets/theme.css")
|
||||
@@ -117,6 +165,8 @@ def main() -> int:
|
||||
)
|
||||
|
||||
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]] = []
|
||||
literal_count = 0
|
||||
|
||||
@@ -140,6 +190,12 @@ def main() -> int:
|
||||
|
||||
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:
|
||||
print(f"FAIL — {len(unresolved)} unresolvable var() reference(s).")
|
||||
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()
|
||||
|
||||
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:
|
||||
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 "
|
||||
"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__":
|
||||
|
||||
@@ -107,6 +107,17 @@ async def create_note(
|
||||
) -> dict:
|
||||
"""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:
|
||||
title: Note title (required).
|
||||
body: Markdown content. Supports [[wikilinks]] to other notes by title.
|
||||
|
||||
@@ -54,6 +54,12 @@ async def create_process(
|
||||
) -> dict:
|
||||
"""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
|
||||
procedure — the steps, the taxonomy, the quality bar, the failure modes
|
||||
worth guarding. It must not force anything the invoking conversation
|
||||
|
||||
@@ -407,6 +407,13 @@ async def create_project_rule(
|
||||
the rule is returned in get_project's applicable_rules (under
|
||||
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
|
||||
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
|
||||
|
||||
@@ -115,6 +115,13 @@ async def create_snippet(
|
||||
"""Record a shape in the project's pattern library, so every later
|
||||
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,
|
||||
a route handler, a service class, a helper, a test scaffold — not only
|
||||
when something is judged "reusable": the builder of the first instance
|
||||
|
||||
@@ -135,6 +135,13 @@ async def create_task(
|
||||
) -> dict:
|
||||
"""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:
|
||||
title: Task title (required).
|
||||
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,
|
||||
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
|
||||
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.
|
||||
|
||||
Creates a MILESTONE that IS the plan: its `body` is seeded with a design
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user