Files
FabledScribe/frontend/src/views/LessonDetailView.vue
T
bvandeusenandClaude Opus 5 edbc31f8ca
CI & Build / Python lint (push) Successful in 8s
CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 57s
CI & Build / Python tests (push) Successful in 1m41s
CI & Build / Build & push image (push) Successful in 36s
feat(lessons): the kind whose whole question is "is this trigger right" was the one kind that could not see its own counts (#4196)
The lesson slot has recorded surfaced-vs-opened since it shipped. Nothing
showed it. `get_lesson`'s REST door attached `usage` to the payload and no
view rendered it; the listing did not attach it at all, and neither MCP door
did.

#4196 asks when a lesson that keeps getting followed should become a rule, and
names the trap in the same breath: raw frequency cannot separate "this should
bind" from "this trigger is too broad", and the second is the commoner reading
by a wide margin. Surfaced-AND-opened can separate them. Neither question is
answerable by a reader who cannot see the numbers, which is why this is the
first step and not the threshold.

NO THRESHOLD IS PROPOSED HERE, deliberately. The corpus today is 10 lessons
with 7 recorded surfacings and 3 opens, over about fifteen hours of usage
data. A promotion rule fitted to that would be fitting noise — #3311's failure,
and the warning lesson #4228 was written to carry. `UsageBadge` already
declines to render a verdict under three surfacings for the same reason. So
#4196 stays open: its subject, the promotion path, is still unbuilt. What
lands is the evidence it needs.

  - REST `GET /api/lessons` and MCP `list_lessons` attach `usage` to every
    row, from one aggregate per page rather than a per-row read, which would
    be N+1 by construction. Every row carries the key zero-filled, so "never
    surfaced" is a state a reader can see rather than a missing field they
    have to interpret.
  - MCP `get_lesson` attaches it too, and reads it BEFORE recording its own
    pull. That door records a pull on every open — it has to, or the kind sits
    permanently at zero — which makes the order load-bearing in a way it is
    not for a kind that only counts. The REST detail door already ordered it
    this way; the two now agree about what the number means.
  - `LessonDetailView` renders `UsageBadge` (snippet #3460) rather than
    re-spelling the chip, with the advice keyed to this kind: a lesson that is
    repeatedly offered and never opened is usually keyed to a situation nobody
    is in, so it points at re-keying `when_to_apply`, not at deleting the
    claim.

Guards, in the two styles this pair of doors already uses: the MCP side driven
behaviourally through mocks, including the call ORDER for `get_lesson`; the
REST side on structure like its siblings in test_lesson_rest_door.py, because
the route is decorated and returns a Quart response. Rule 167's falsifier is
included.

KNOWN GAP, not fixed here: `KnowledgeView` is the only lesson LIST in the UI
and it reads `/knowledge`, not `/lessons` — so the REST listing change reaches
`frontend/src/api/lessons.ts::listLessons`, which currently has no consumer.
The agent-facing listing does reach a reader today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
2026-09-21 02:33:33 -04:00

309 lines
9.4 KiB
Vue

<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 UsageBadge from "@/components/UsageBadge.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>
<div class="ld-what-row">
<h1 class="ld-what">{{ lesson.what }}</h1>
<!-- Lessons collected surfaced-vs-opened from the day the slot shipped
and showed it nowhere (#4196). It belongs here above anywhere
else: the reading that matters is almost never "delete this", it
is "the trigger is keyed to a situation nobody is in", and the
place to act on that is the lesson you are already looking at. -->
<UsageBadge
:usage="lesson.usage"
noun="lesson"
dead-weight-advice="Repeatedly offered and never opened usually means the trigger fires on the wrong situation re-key `when_to_apply` rather than deleting the claim."
/>
</div>
<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-row {
display: flex;
align-items: baseline;
gap: var(--fs-space-2);
flex-wrap: wrap;
}
.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>