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:
@@ -0,0 +1,288 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Read a LESSON, and see both ends of what it came from.
|
||||
*
|
||||
* THE TRIGGER LEADS. A lesson is retrieved by the situation it applies to, so
|
||||
* the page opens with that situation rather than with the claim — the same
|
||||
* ordering the editor uses, and for the same reason: the trigger is the half
|
||||
* a reader needs first to decide whether this is for them.
|
||||
*
|
||||
* GLOBAL BY DEFAULT HAS TO BE LEGIBLE. A lesson written on one project will
|
||||
* surface on another, which reads as a bug unless the page says why. The
|
||||
* origin line does exactly that, and says it as a property of the kind rather
|
||||
* than as an apology.
|
||||
*
|
||||
* IT BINDS NOBODY, and the page says so once, plainly. That sentence is the
|
||||
* difference between this kind and a rule, and it is the reason the kind
|
||||
* exists (#3727) — sessions were proposing rules for things that should never
|
||||
* have bound anyone.
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import { deleteLesson, getLesson, type Lesson } from "@/api/lessons";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import TagPill from "@/components/TagPill.vue";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const toast = useToastStore();
|
||||
|
||||
const lesson = ref<Lesson | null>(null);
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const confirming = ref(false);
|
||||
|
||||
const lessonId = computed(() => Number(route.params.id));
|
||||
|
||||
/** The insight rendered — the body minus the lines the service composed, so
|
||||
* the trigger is not printed twice on a page that already leads with it. */
|
||||
const insightHtml = computed(() =>
|
||||
lesson.value?.insight ? renderMarkdown(lesson.value.insight) : "",
|
||||
);
|
||||
|
||||
/** Where each source opens. A task and a note live at different routes, and a
|
||||
* link that guesses wrong is worse than one that is plain. */
|
||||
function sourceHref(rec: { id: number; is_task?: boolean; note_type?: string }) {
|
||||
if (rec.is_task) return `/tasks/${rec.id}`;
|
||||
if (rec.note_type === "snippet") return `/snippets/${rec.id}`;
|
||||
if (rec.note_type === "lesson") return `/lessons/${rec.id}`;
|
||||
return `/notes/${rec.id}`;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
lesson.value = await getLesson(lessonId.value);
|
||||
} catch (e) {
|
||||
error.value = apiErrorMessage(e, "Failed to load this lesson");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
confirming.value = false;
|
||||
try {
|
||||
await deleteLesson(lessonId.value);
|
||||
toast.show("Lesson moved to trash");
|
||||
router.push("/knowledge?type=lesson");
|
||||
} catch (e) {
|
||||
toast.show(apiErrorMessage(e, "Failed to delete this lesson"), "error");
|
||||
}
|
||||
}
|
||||
|
||||
watch(lessonId, load);
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="lesson-detail">
|
||||
<p v-if="loading" class="ld-muted">Loading…</p>
|
||||
<p v-else-if="error" class="ld-error" role="alert">{{ error }}</p>
|
||||
|
||||
<article v-else-if="lesson">
|
||||
<header class="ld-head">
|
||||
<span class="ld-kind">Lesson</span>
|
||||
<!-- Said once, plainly. It is the whole difference from a rule. -->
|
||||
<span class="ld-binds">binds nobody</span>
|
||||
</header>
|
||||
|
||||
<!-- The trigger leads, in its own block: it is what a reader needs first
|
||||
to decide whether this applies to them. -->
|
||||
<section class="ld-trigger">
|
||||
<h2 class="ld-trigger-label">When this applies</h2>
|
||||
<p class="ld-trigger-text">{{ lesson.when_to_apply }}</p>
|
||||
</section>
|
||||
|
||||
<h1 class="ld-what">{{ lesson.what }}</h1>
|
||||
|
||||
<div
|
||||
v-if="insightHtml"
|
||||
class="ld-insight markdown-body"
|
||||
v-html="insightHtml"
|
||||
/>
|
||||
<p v-else class="ld-muted ld-empty">
|
||||
No detail was recorded — the claim above is the whole lesson.
|
||||
</p>
|
||||
|
||||
<!-- What taught it. The provenance is the point: a lesson that loses its
|
||||
incidents loses its evidence. -->
|
||||
<section
|
||||
v-if="lesson.learned_from_records?.length"
|
||||
class="ld-panel"
|
||||
>
|
||||
<h2 class="ld-panel-label">Learned from</h2>
|
||||
<ul class="ld-sources">
|
||||
<li v-for="rec in lesson.learned_from_records" :key="rec.id">
|
||||
<router-link :to="sourceHref(rec)" class="ld-link">
|
||||
{{ rec.title }}
|
||||
</router-link>
|
||||
<span v-if="rec.status" class="ld-source-status">{{ rec.status }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section v-if="lesson.tags?.length" class="ld-tags">
|
||||
<TagPill v-for="t in lesson.tags" :key="t" :tag="t" />
|
||||
</section>
|
||||
|
||||
<!-- Global-by-default, stated as a property rather than an apology. A
|
||||
lesson meeting you on a project it was not written on is the kind
|
||||
working, and the page has to say so or it reads as a bug. -->
|
||||
<footer class="ld-origin">
|
||||
<p v-if="lesson.project_id">
|
||||
Learned on another project, and offered everywhere — a lesson is
|
||||
retrieved by the situation it names, never by where it was written.
|
||||
</p>
|
||||
<p v-else>
|
||||
Not tied to a project. Offered wherever the situation it names comes
|
||||
up.
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
<div class="ld-actions">
|
||||
<router-link :to="`/lessons/${lesson.id}/edit`" class="ld-primary">
|
||||
Edit
|
||||
</router-link>
|
||||
<button type="button" class="ld-ghost" @click="confirming = true">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<ConfirmDialog
|
||||
v-if="confirming"
|
||||
title="Delete this lesson?"
|
||||
message="It moves to the trash and can be restored."
|
||||
@confirm="remove"
|
||||
@cancel="confirming = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lesson-detail {
|
||||
max-width: 780px;
|
||||
margin: 0 auto;
|
||||
padding: var(--fs-layout-page-pad);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
|
||||
.ld-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
/* Neutral, matching the browse badge. A hue here would make the softest
|
||||
record in the corpus look like the loudest. */
|
||||
.ld-kind {
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-secondary);
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ld-binds { color: var(--fs-text-tertiary); font-size: 0.8rem; }
|
||||
|
||||
.ld-trigger {
|
||||
padding: 0.9rem 1rem;
|
||||
border-left: 2px solid var(--fs-accent);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-raised);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.ld-trigger-label {
|
||||
margin: 0 0 0.3rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.ld-trigger-text { margin: 0; font-size: 0.98rem; line-height: 1.5; }
|
||||
|
||||
.ld-what {
|
||||
margin: 0 0 1.25rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.ld-insight { line-height: 1.6; font-size: 0.93rem; }
|
||||
.ld-empty { font-style: italic; }
|
||||
|
||||
.ld-panel {
|
||||
margin-top: 1.75rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.ld-panel-label {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.ld-sources { margin: 0; padding-left: 1.1rem; }
|
||||
.ld-sources li { margin-bottom: 0.3rem; font-size: 0.9rem; }
|
||||
.ld-link { color: var(--fs-accent); }
|
||||
.ld-source-status {
|
||||
margin-left: 0.4rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.ld-tags { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 1.25rem; }
|
||||
|
||||
.ld-origin {
|
||||
margin-top: 1.5rem;
|
||||
color: var(--fs-text-secondary);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.ld-origin p { margin: 0; }
|
||||
|
||||
.ld-actions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1.75rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.ld-primary {
|
||||
padding: 0.45rem 1rem;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-accent);
|
||||
color: var(--fs-accent-fg);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.ld-ghost {
|
||||
padding: 0.45rem 1rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--fs-text-primary);
|
||||
font: inherit;
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ld-muted { color: var(--fs-text-secondary); font-size: 0.88rem; }
|
||||
.ld-error {
|
||||
padding: 0.6rem 0.8rem;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: color-mix(in srgb, var(--fs-error) 12%, var(--fs-surface-raised));
|
||||
color: color-mix(in srgb, var(--fs-error) 55%, var(--fs-text-primary));
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user