feat(lessons): both records show the link in the web UI (milestone 440 step 7, #4635)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 1m53s
CI & Build / Build & push image (push) Successful in 41s

The lesson page gains an "Instance of" panel that holds one of three answers.
Unjudged is the fall-through, so it is stated rather than left blank:
- the rule(s) it was judged an instance of;
- "No rule — <why>";
- "Not yet judged".

Suggested links show what they rest on (distinct situations, and projects
when more than one), with Confirm / Not an instance for a reader who can
write. Rejected links stay listed with their reason.

The rule slide-over lists the lessons that are instances of it, plus the
suggestions waiting on a judgment. Each entry links through to the other
record, and kind and state wear the existing .rule-chip.

The write check moves to utils/permission.ts. The copy on the snippet page
looked for "edit", which the server never sends, so shared editors saw a
read-only page (#4640 "The snippet page hid its edit controls from shared
editors"). Guards pin the client's unions and write levels to the
service's, the model's and access.py's own values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 15:46:27 -04:00
co-authored by Claude Opus 5.5
parent 6d3dca0af5
commit 75cefe60e4
7 changed files with 368 additions and 6 deletions
@@ -27,6 +27,14 @@ const expiresWhen = ref("");
const relations = computed(() => store.currentRule?.relations ?? []);
// The lessons that point at this rule (milestone 440): confirmed instances
// first, then suggestions waiting on a judgment. A rejected lesson does not
// point at the rule, so it is not listed here — it stays readable on the
// lesson, where the judgment was made.
const lessons = computed(() =>
(store.currentRule?.lessons ?? []).filter((l) => l.state !== "rejected"),
);
// The label a reader needs to judge an edge, not the stored token.
const RELATION_LABEL: Record<string, { outgoing: string; incoming: string }> = {
co_surfaces: { outgoing: "arrives with", incoming: "arrives with" },
@@ -279,6 +287,21 @@ watch(() => props.ruleId, load);
</p>
</section>
<section v-if="lessons.length" class="relations">
<h3>Lessons that are instances of it</h3>
<ul>
<li v-for="l in lessons" :key="l.id" class="relation">
<router-link :to="`/lessons/${l.id}`" class="relation-target">{{ l.title }}</router-link>
<span v-if="l.state === 'suggested'" class="rule-chip">suggested</span>
<span v-if="l.note" class="relation-note">{{ l.note }}</span>
</li>
</ul>
<p class="field-note">
The situations that keep proving this rule. A confirmed lesson brings the rule along
when it surfaces; a suggested one waits for a judgment on the lesson's page.
</p>
</section>
<label>
Why
<textarea v-model="why" rows="4" placeholder="Rationale — the reason this rule exists." />
@@ -394,3 +417,8 @@ legend { padding: 0 0.35rem; font-size: 0.8rem; color: var(--fs-text-tertiary);
.trash, .close { background: none; border: none; cursor: pointer; opacity: 0.6; font-size: 1.25em; }
.trash:hover, .close:hover { opacity: 1; }
</style>
<!-- `.rule-chip` for the "suggested" marker on a lesson — the chip the rule
panes already use, loaded here because the slide-over can open with no
pane that loads it (a link straight to ?rule=N). -->
<style src="@/assets/rules-shared.css" />