feat(lessons): a lesson is readable, writable and browsable by a human (#3734)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Failing after 31s
CI & Build / integration (push) Successful in 48s
CI & Build / Python tests (push) Successful in 1m33s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Failing after 31s
CI & Build / integration (push) Successful in 48s
CI & Build / Python tests (push) Successful in 1m33s
CI & Build / Build & push image (push) Skipped
Step 7's actual UI. Before this the frontend had zero lesson code — the kind existed for agents only, which is rule 27 failing. THE EDITOR ASKS FOR THE TRIGGER BY NAME, and leads with it. Three fields — the trigger, the claim, the detail — never one markdown box. That is the design step 1 settled, and the evidence is blunt: the snippet corpus carries a trigger on every record with no guard anywhere, because a service composes the title from a named parameter. What is at 100% is a named structured field, not a writer remembering a convention. The trigger gets the most room, its own explanation, and a save button that refuses without it and says why. The form shows the composed title live, so the writer is agreeing to a document they can read rather than one assembled out of sight. A 409 from the duplicate gate is rendered as the record that already covers the moment, with a link to improve it and an explicit override — not as a failure. THE BROWSE VOCABULARY GAINS THE KIND, which #3161 warned this step not to get wrong: a facet chip, a badge label, and routing to `/lessons/:id` rather than the note editor, which cannot edit a trigger. The badge is neutral alongside snippet and process — a hue would make the softest record in the corpus look like the loudest, next to a rule that actually binds. BOTH DIRECTIONS OF THE PROVENANCE. The detail page resolves `learned_from` to titles rather than bare ids, because "#4181" tells a reader nothing about whether it is worth opening. And `LessonsTaughtPanel` answers the reverse on the record's own page — the direction the task body calls the one that gets forgotten. It has no author to type it, which is exactly why it tends never to get built. A component, not markup in the task editor, so the same panel mounts on any record a lesson can cite instead of being written a second time (#3207). Silent when empty: most records taught no lesson, and a panel that says "None yet" everywhere is one people learn to skip. GLOBAL-BY-DEFAULT IS MADE LEGIBLE. A lesson meeting you on a project it was not written on reads as a bug unless the page says otherwise, so the origin line says it as a property of the kind rather than as an apology. Design system tokens throughout; no new raw hex. `--fs-error` rather than `--fs-danger` — 31 uses against 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
FileText,
|
||||
CheckSquare,
|
||||
Workflow,
|
||||
Lightbulb,
|
||||
Search,
|
||||
Share2,
|
||||
ShieldCheck,
|
||||
@@ -26,7 +27,7 @@ const router = useRouter();
|
||||
|
||||
interface KnowledgeItem {
|
||||
id: number;
|
||||
note_type: "note" | "task" | "process" | "snippet";
|
||||
note_type: "note" | "task" | "process" | "snippet" | "lesson";
|
||||
title: string;
|
||||
snippet: string;
|
||||
tags: string[];
|
||||
@@ -46,7 +47,8 @@ interface KnowledgeItem {
|
||||
|
||||
// ─── The facet vocabulary ─────────────────────────────────────────────────────
|
||||
// Mirrors services/knowledge._FACETS, which is where it is defined for real.
|
||||
// A facet spans BOTH typing axes — a record TYPE (note / process / snippet) or
|
||||
// A facet spans BOTH typing axes — a record TYPE (note / process / snippet /
|
||||
// lesson) or
|
||||
// a task KIND (`task` for any, else issue / spike) — because that is what this
|
||||
// feed actually holds.
|
||||
//
|
||||
@@ -54,7 +56,7 @@ interface KnowledgeItem {
|
||||
// it has no chip: retired in 0066, it kept a chip of its own for longer than
|
||||
// `issue` — 17% of every task here — went without one (#3128). Those rows are
|
||||
// still reachable under Tasks, wearing a Plan badge.
|
||||
type Facet = "" | "note" | "task" | "issue" | "spike" | "snippet" | "process";
|
||||
type Facet = "" | "note" | "task" | "issue" | "spike" | "snippet" | "process" | "lesson";
|
||||
|
||||
// The facets that select TASKS. Kinds are subsets of `task`, so any of them
|
||||
// means the duplicate report should be comparing tasks.
|
||||
@@ -67,6 +69,7 @@ const FACET_CHIPS: [Exclude<Facet, "">, string][] = [
|
||||
["spike", "Spikes"],
|
||||
["snippet", "Snippets"],
|
||||
["process", "Processes"],
|
||||
["lesson", "Lessons"],
|
||||
];
|
||||
|
||||
// ─── View mode ────────────────────────────────────────────────────────────────
|
||||
@@ -153,6 +156,11 @@ function createNew(type: string) {
|
||||
newNoteMenuOpen.value = false;
|
||||
if (type === "task") {
|
||||
router.push("/tasks/new");
|
||||
} else if (type === "lesson") {
|
||||
// Its own editor, not /notes/new?type=lesson: the note editor offers one
|
||||
// markdown box, and a lesson written that way saves without a trigger and
|
||||
// never surfaces. The form has to ASK for the field by name.
|
||||
router.push("/lessons/new");
|
||||
} else {
|
||||
router.push(type === "note" ? "/notes/new" : `/notes/new?type=${type}`);
|
||||
}
|
||||
@@ -328,6 +336,8 @@ function openItem(item: KnowledgeItem) {
|
||||
router.push(`/tasks/${item.id}`);
|
||||
} else if (item.note_type === 'snippet') {
|
||||
router.push(`/snippets/${item.id}`);
|
||||
} else if (item.note_type === 'lesson') {
|
||||
router.push(`/lessons/${item.id}`);
|
||||
} else {
|
||||
router.push(`/notes/${item.id}`);
|
||||
}
|
||||
@@ -417,6 +427,10 @@ onUnmounted(() => {
|
||||
<Workflow :size="16" />
|
||||
Process
|
||||
</button>
|
||||
<button @click="createNew('lesson')">
|
||||
<Lightbulb :size="16" />
|
||||
Lesson
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -574,6 +588,7 @@ 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 v-else-if="item.note_type === 'snippet'">Snippet</span>
|
||||
<span v-else-if="item.note_type === 'lesson'">Lesson</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 /
|
||||
@@ -962,7 +977,11 @@ onUnmounted(() => {
|
||||
isn't an alarm reads better as plain. Standard body pair, so the contrast
|
||||
is the one the palette already guarantees. */
|
||||
.badge--snippet,
|
||||
.badge--process { background: var(--fs-surface-raised); color: var(--fs-text-secondary); }
|
||||
.badge--process,
|
||||
/* A lesson joins the neutral pair for the same reason, and one of its own: it
|
||||
binds nobody. A hue here would make the softest record in the corpus look
|
||||
like the loudest, beside a rule that actually is binding. */
|
||||
.badge--lesson { background: var(--fs-surface-raised); color: var(--fs-text-secondary); }
|
||||
|
||||
.k-card-body { flex: 1; padding-right: 40px; }
|
||||
.k-card-title {
|
||||
|
||||
Reference in New Issue
Block a user