CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 52s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 2m2s
CI & Build / Build & push image (push) Successful in 45s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
526 lines
22 KiB
Vue
526 lines
22 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref, watch, onMounted } from "vue";
|
||
import { useRulebooksStore } from "@/stores/rulebooks";
|
||
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||
import { useMomentsStore } from "@/stores/moments";
|
||
import RuleHistoryPanel from "@/components/rules/RuleHistoryPanel.vue";
|
||
import RuleHomePicker from "@/components/rules/RuleHomePicker.vue";
|
||
import type { Rule, RuleKind } from "@/api/rulebooks";
|
||
|
||
const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
|
||
const emit = defineEmits<{ close: [] }>();
|
||
|
||
const store = useRulebooksStore();
|
||
const canon = useCanonicalSystemsStore();
|
||
const momentsStore = useMomentsStore();
|
||
const title = ref("");
|
||
const statement = ref("");
|
||
const whenToApply = ref("");
|
||
// Defaults to `rule`, matching the server's column default. The safe
|
||
// direction is the one that binds: a preference mislabelled as a rule is
|
||
// followed too faithfully, where a rule mislabelled as a preference is one a
|
||
// session may quietly rewrite.
|
||
const kind = ref<RuleKind>("rule");
|
||
const systemIds = ref<number[]>([]);
|
||
// The moments this rule is mounted on (milestone 458). Catalog moments are
|
||
// ticked; a named procedure's moment (`skill.<name>`) is typed, since its
|
||
// name is the procedure's own and no list here could know it.
|
||
const ruleMoments = ref<string[]>([]);
|
||
const procedureDraft = ref("");
|
||
const procedureError = ref("");
|
||
const SKILL_PREFIX = "skill.";
|
||
const SKILL_NAME = /^[a-z0-9][a-z0-9_:-]*$/;
|
||
const momentCatalog = computed(() => momentsStore.data?.moments ?? []);
|
||
const procedureMoments = computed(() =>
|
||
ruleMoments.value.filter((m) => m.startsWith(SKILL_PREFIX)),
|
||
);
|
||
const why = ref("");
|
||
const howToApply = ref("");
|
||
const verifyWith = ref("");
|
||
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" },
|
||
overrides: { outgoing: "overrides", incoming: "is overridden by" },
|
||
elaborates: { outgoing: "elaborates", incoming: "is elaborated by" },
|
||
};
|
||
|
||
function relationLabel(kind: string, direction: "outgoing" | "incoming") {
|
||
return RELATION_LABEL[kind]?.[direction] ?? kind;
|
||
}
|
||
|
||
function toggleMoment(name: string) {
|
||
const at = ruleMoments.value.indexOf(name);
|
||
if (at >= 0) ruleMoments.value.splice(at, 1);
|
||
else ruleMoments.value.push(name);
|
||
}
|
||
|
||
// The same check the server makes (services/moments.is_moment), so a typo is
|
||
// caught at the field rather than as a failed save.
|
||
function addProcedureMoment() {
|
||
const raw = procedureDraft.value.trim().toLowerCase();
|
||
const name = raw.startsWith(SKILL_PREFIX) ? raw : SKILL_PREFIX + raw;
|
||
if (!SKILL_NAME.test(name.slice(SKILL_PREFIX.length))) {
|
||
procedureError.value = "A procedure name is lowercase letters, digits, - _ and :, with no spaces.";
|
||
return;
|
||
}
|
||
procedureError.value = "";
|
||
if (!ruleMoments.value.includes(name)) ruleMoments.value.push(name);
|
||
procedureDraft.value = "";
|
||
}
|
||
|
||
function toggleSystem(id: number) {
|
||
const at = systemIds.value.indexOf(id);
|
||
if (at >= 0) systemIds.value.splice(at, 1);
|
||
else systemIds.value.push(id);
|
||
}
|
||
|
||
const isCreating = ref(props.ruleId === null);
|
||
|
||
// The stored stamp, not the draft: it describes the check that was RUN, and
|
||
// an unsaved edit to the textarea has not been run against anything.
|
||
const verifiedAt = computed(() => store.currentRule?.verified_at ?? null);
|
||
const savedCheck = computed(() => store.currentRule?.verify_with ?? "");
|
||
// Built here rather than in the template: same shape as the server's
|
||
// last_verified_label, and it keeps the null-narrowing in TypeScript's reach.
|
||
const stampLabel = computed(() =>
|
||
verifiedAt.value ? `Last checked ${verifiedAt.value.slice(0, 10)}` : "Never checked",
|
||
);
|
||
const verifying = ref(false);
|
||
|
||
async function verify(stillTrue: boolean) {
|
||
if (props.ruleId === null) return;
|
||
verifying.value = true;
|
||
try {
|
||
await store.verifyRule(props.ruleId, stillTrue);
|
||
} finally {
|
||
verifying.value = false;
|
||
}
|
||
}
|
||
|
||
async function load() {
|
||
if (props.ruleId !== null) {
|
||
await store.fetchRule(props.ruleId);
|
||
const r = store.currentRule;
|
||
if (r) {
|
||
title.value = r.title;
|
||
statement.value = r.statement;
|
||
whenToApply.value = r.when_to_apply || "";
|
||
kind.value = r.kind;
|
||
systemIds.value = (r.systems ?? []).map((sys) => sys.id);
|
||
ruleMoments.value = [...(r.moments ?? [])];
|
||
why.value = r.why || "";
|
||
howToApply.value = r.how_to_apply || "";
|
||
verifyWith.value = r.verify_with || "";
|
||
expiresWhen.value = r.expires_when || "";
|
||
}
|
||
} else {
|
||
title.value = "";
|
||
statement.value = "";
|
||
whenToApply.value = "";
|
||
kind.value = "rule";
|
||
systemIds.value = [];
|
||
ruleMoments.value = [];
|
||
why.value = "";
|
||
howToApply.value = "";
|
||
verifyWith.value = "";
|
||
expiresWhen.value = "";
|
||
}
|
||
procedureDraft.value = "";
|
||
procedureError.value = "";
|
||
await Promise.all([canon.fetchCatalog(), momentsStore.load()]);
|
||
}
|
||
|
||
async function save() {
|
||
if (!title.value.trim() || !statement.value.trim()) {
|
||
emit("close");
|
||
return;
|
||
}
|
||
const fields = {
|
||
title: title.value,
|
||
statement: statement.value,
|
||
when_to_apply: whenToApply.value,
|
||
kind: kind.value,
|
||
// Always sent, so clearing the last area actually clears it — the server
|
||
// reads a list as "these ARE the areas now".
|
||
system_ids: systemIds.value,
|
||
// Always sent, for system_ids' reason: the server reads the list as the
|
||
// whole set, so unticking the last moment actually unmounts the rule.
|
||
moments: ruleMoments.value,
|
||
why: why.value,
|
||
how_to_apply: howToApply.value,
|
||
// Always sent, including empty. The REST door maps "" to NULL, so
|
||
// clearing a field here actually clears it — the MCP door's "" means
|
||
// "leave unchanged" and needs an explicit clear_fields list instead.
|
||
verify_with: verifyWith.value,
|
||
expires_when: expiresWhen.value,
|
||
};
|
||
if (isCreating.value && props.topicId !== null) {
|
||
await store.createRule(props.topicId, fields);
|
||
} else if (props.ruleId !== null) {
|
||
await store.updateRule(props.ruleId, fields);
|
||
}
|
||
emit("close");
|
||
}
|
||
|
||
// A moved rule has left the topic this view lists (or joined another). The
|
||
// store re-places it, then the editor saves any text edits and closes, the
|
||
// way the backdrop does.
|
||
async function onMoved(rule: Rule) {
|
||
store.placeMovedRule(rule);
|
||
await save();
|
||
}
|
||
|
||
async function remove() {
|
||
if (props.ruleId === null) return;
|
||
if (!confirm("Delete this rule? This cannot be undone.")) return;
|
||
await store.deleteRule(props.ruleId);
|
||
emit("close");
|
||
}
|
||
|
||
onMounted(load);
|
||
watch(() => props.ruleId, load);
|
||
</script>
|
||
|
||
<template>
|
||
<div class="backdrop" @click="save">
|
||
<aside class="slide-over" @click.stop>
|
||
<header>
|
||
<h2>{{ `${isCreating ? "New" : "Edit"} ${kind === "preference" ? "preference" : "rule"}` }}</h2>
|
||
<button v-if="!isCreating" class="trash" @click="remove" aria-label="Delete">🗑</button>
|
||
<button class="close" @click="save" aria-label="Close">×</button>
|
||
</header>
|
||
<label>
|
||
Title
|
||
<input v-model="title" placeholder="e.g. dev is home" />
|
||
</label>
|
||
<fieldset class="kind">
|
||
<legend>What kind of instruction is this?</legend>
|
||
<label class="kind-opt">
|
||
<input v-model="kind" type="radio" value="rule" />
|
||
<span>
|
||
<strong>Rule</strong> — must be followed. Ignoring it breaks
|
||
something or crosses a boundary. It changes when you change it.
|
||
</span>
|
||
</label>
|
||
<label class="kind-opt">
|
||
<input v-model="kind" type="radio" value="preference" />
|
||
<span>
|
||
<strong>Preference</strong> — how you want work done. Ignoring it
|
||
costs consistency, not correctness, and <em>Scribe updates it as
|
||
the work teaches it</em>.
|
||
</span>
|
||
</label>
|
||
<p class="field-note">
|
||
The test is what happens when someone does not do it. Something
|
||
breaks — a rule. The tenth time goes differently from the ninth — a
|
||
preference.
|
||
</p>
|
||
</fieldset>
|
||
<p v-if="kind === 'preference'" class="kind-drift">
|
||
Sessions rewrite preferences without asking, which is what makes them
|
||
stay current. Every rewrite is kept, and
|
||
<strong>Recent changes</strong> lists them with what taught each one.
|
||
</p>
|
||
<label>
|
||
Statement <span class="required">*</span>
|
||
<textarea v-model="statement" rows="3" placeholder="The actionable instruction (1-2 sentences)." />
|
||
</label>
|
||
<label :class="{ 'trigger-missing': !whenToApply.trim() }">
|
||
When to apply <span class="required">*</span>
|
||
<textarea
|
||
v-model="whenToApply"
|
||
rows="3"
|
||
placeholder="The moment, in the words a session actually produces — “about to run git push with an earlier CI run unread”, not “when pacing actions”."
|
||
/>
|
||
</label>
|
||
<p v-if="!whenToApply.trim()" class="trigger-warning">
|
||
<strong>Without this, the rule will never reach a session.</strong>
|
||
Nothing is preloaded: a rule arrives when what someone is doing matches
|
||
its trigger, so an empty trigger leaves the rule findable by nobody.
|
||
</p>
|
||
|
||
<fieldset v-if="momentCatalog.length" class="moments">
|
||
<legend>Moments it arrives at</legend>
|
||
<p class="field-note intro">
|
||
Whenever one of these happens, this rule is delivered — whatever the
|
||
words of the work look like. Use it for a rule about <em>when</em>
|
||
something is done rather than what it is about: a rule on when work
|
||
counts as finished belongs where work is finished and reported, and
|
||
nothing said there needs to resemble it.
|
||
</p>
|
||
<label v-for="m in momentCatalog" :key="m.name" class="area-opt">
|
||
<input
|
||
type="checkbox"
|
||
:checked="ruleMoments.includes(m.name)"
|
||
@change="toggleMoment(m.name)"
|
||
/>
|
||
<span>
|
||
<code class="moment-name">{{ m.name }}</code>
|
||
<span class="moment-means">{{ m.means }}</span>
|
||
</span>
|
||
</label>
|
||
<div v-if="procedureMoments.length" class="procedure-moments">
|
||
<span v-for="m in procedureMoments" :key="m" class="rule-chip procedure-chip">
|
||
<code>{{ m }}</code>
|
||
<button
|
||
type="button"
|
||
class="chip-remove"
|
||
:aria-label="`Unmount from ${m}`"
|
||
@click="toggleMoment(m)"
|
||
>×</button>
|
||
</span>
|
||
</div>
|
||
<div class="procedure-add">
|
||
<input
|
||
v-model="procedureDraft"
|
||
aria-label="A named procedure this rule arrives with"
|
||
placeholder="skill.release-notes — when a named procedure is loaded"
|
||
@keydown.enter.prevent="addProcedureMoment"
|
||
/>
|
||
<button
|
||
type="button"
|
||
class="btn-secondary btn-sm"
|
||
:disabled="!procedureDraft.trim()"
|
||
@click="addProcedureMoment"
|
||
>Add</button>
|
||
</div>
|
||
<p v-if="procedureError" class="trigger-warning">{{ procedureError }}</p>
|
||
</fieldset>
|
||
|
||
<fieldset v-if="canon.catalog.length" class="areas">
|
||
<legend>Areas this rule is about</legend>
|
||
<label v-for="entry in canon.catalog" :key="entry.id" class="area-opt">
|
||
<input
|
||
type="checkbox"
|
||
:checked="systemIds.includes(entry.id)"
|
||
@change="toggleSystem(entry.id)"
|
||
/>
|
||
<span>{{ entry.name }}</span>
|
||
</label>
|
||
<p class="field-note">
|
||
What lets this rule reach a project working in that area.
|
||
</p>
|
||
</fieldset>
|
||
|
||
<fieldset class="check">
|
||
<legend>Can this rule go stale?</legend>
|
||
<p class="field-note intro">
|
||
Most rules are <em>decisions</em> — they have no truth value and change only when you
|
||
change them. Leave this empty for those. Fill it in when the rule asserts a
|
||
<em>fact</em> about something outside your control, because those go false quietly.
|
||
</p>
|
||
<label>
|
||
How to check it is still true
|
||
<textarea
|
||
v-model="verifyWith"
|
||
rows="2"
|
||
placeholder="A command, a path, a query — something runnable beats prose."
|
||
/>
|
||
</label>
|
||
<label>
|
||
What would end it
|
||
<textarea
|
||
v-model="expiresWhen"
|
||
rows="2"
|
||
placeholder="A state, not a date — “when the runner can be given a bash shell”."
|
||
/>
|
||
</label>
|
||
|
||
<div v-if="savedCheck" class="stamp">
|
||
<span class="stamp-age" :class="{ unchecked: !verifiedAt }">{{ stampLabel }}</span>
|
||
<span class="stamp-actions">
|
||
<button type="button" :disabled="verifying" @click="verify(true)">Still true</button>
|
||
<button type="button" :disabled="verifying" @click="verify(false)">No longer true</button>
|
||
</span>
|
||
</div>
|
||
<p v-if="savedCheck" class="field-note">
|
||
Record this after actually running the check, never on the strength of the rule
|
||
sounding plausible. “No longer true” deliberately stores nothing — the rule is wrong,
|
||
not in a state worth recording, so it stays at the top of the sweep until you fix or
|
||
retire it.
|
||
</p>
|
||
</fieldset>
|
||
|
||
<RuleHomePicker
|
||
v-if="!isCreating && ruleId !== null && store.currentRule"
|
||
:rule-id="ruleId"
|
||
:topic-id="store.currentRule.topic_id"
|
||
:project-id="store.currentRule.project_id"
|
||
@moved="onMoved"
|
||
/>
|
||
|
||
<section v-if="relations.length" class="relations">
|
||
<h3>Related rules</h3>
|
||
<ul>
|
||
<li v-for="rel in relations" :key="rel.id" class="relation">
|
||
<span class="relation-kind">{{ relationLabel(rel.kind, rel.direction) }}</span>
|
||
<span class="relation-target">rule #{{ rel.rule_id }}</span>
|
||
<span v-if="rel.note" class="relation-note">{{ rel.note }}</span>
|
||
</li>
|
||
</ul>
|
||
<p class="field-note">
|
||
Rules that <em>fail together</em> are linked, never merged — a merged rule cannot be
|
||
cited or surfaced a clause at a time.
|
||
</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." />
|
||
</label>
|
||
<label>
|
||
How to apply
|
||
<textarea v-model="howToApply" rows="4" placeholder="When / where this kicks in." />
|
||
</label>
|
||
|
||
<!-- Only on an existing rule: a rule being created has no past, and an
|
||
"Edit history — none" line on a blank form reads as a broken panel.
|
||
Keyed on ruleId so switching rules reloads rather than showing the
|
||
previous rule's history under the new one's text. -->
|
||
<RuleHistoryPanel
|
||
v-if="!isCreating && ruleId !== null"
|
||
:key="ruleId"
|
||
:rule-id="ruleId"
|
||
:current="store.currentRule"
|
||
/>
|
||
</aside>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.kind { border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md); padding: var(--fs-space-3); margin: var(--fs-space-3) 0; }
|
||
.kind legend { font-size: var(--fs-size-tiny); text-transform: uppercase; letter-spacing: var(--fs-tracking-tiny); color: var(--fs-text-tertiary); padding: 0 var(--fs-space-2); }
|
||
.kind-opt { display: flex; align-items: flex-start; gap: var(--fs-space-2); margin-bottom: var(--fs-space-2); font-size: var(--fs-size-body-sm); line-height: var(--fs-leading-body); }
|
||
.kind-opt input { margin-top: 0.2rem; accent-color: var(--fs-accent); flex: none; }
|
||
.kind-drift {
|
||
margin: 0 0 var(--fs-space-3); padding: var(--fs-space-2);
|
||
font-size: var(--fs-size-tiny); line-height: var(--fs-leading-body);
|
||
color: var(--fs-text-secondary);
|
||
background: var(--fs-accent-soft); border-radius: var(--fs-radius-sm);
|
||
}
|
||
|
||
.backdrop {
|
||
position: fixed; inset: 0;
|
||
background: rgba(0, 0, 0, 0.4);
|
||
z-index: 100;
|
||
}
|
||
.slide-over {
|
||
position: fixed; top: 0; right: 0; bottom: 0;
|
||
width: min(520px, 90vw);
|
||
background: var(--fs-surface-hover);
|
||
border-left: 2px solid var(--fs-accent);
|
||
padding: 1.5rem;
|
||
overflow-y: auto;
|
||
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.3);
|
||
}
|
||
header { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
|
||
header h2 {
|
||
flex: 1; margin: 0;
|
||
font-family: Fraunces, serif; font-style: italic;
|
||
}
|
||
label { display: block; margin-bottom: 1rem; }
|
||
.required { color: var(--fs-accent); }
|
||
input, textarea {
|
||
width: 100%; margin-top: 0.25rem;
|
||
background: var(--fs-surface-page); color: inherit;
|
||
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
||
padding: 0.5rem; font: inherit;
|
||
font-family: inherit;
|
||
}
|
||
fieldset { border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md); padding: 0.75rem; margin-bottom: 1rem; }
|
||
legend { padding: 0 0.35rem; font-size: 0.8rem; color: var(--fs-text-tertiary); }
|
||
.area-opt { display: flex; align-items: flex-start; gap: 0.5rem; margin-bottom: 0.4rem; font-size: 0.88rem; }
|
||
.area-opt input { width: auto; margin-top: 0.2rem; accent-color: var(--fs-accent); }
|
||
.trigger-missing textarea { border-color: var(--fs-warning); }
|
||
/* The moment picker (milestone 458): the name is what a session reads in a
|
||
delivered line, so it is shown as written; the meaning is the gloss. */
|
||
.moments { margin-bottom: 1rem; }
|
||
.moments .intro { margin-top: 0; margin-bottom: var(--fs-space-3); }
|
||
.moments .moment-name { font-size: var(--fs-size-tiny); }
|
||
.moment-means { display: block; font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); line-height: var(--fs-leading-body); }
|
||
.procedure-moments { display: flex; flex-wrap: wrap; gap: var(--fs-space-2); margin: var(--fs-space-2) 0; }
|
||
.procedure-chip { display: inline-flex; align-items: center; gap: var(--fs-space-1); }
|
||
.procedure-add { display: flex; gap: var(--fs-space-2); align-items: center; margin-top: var(--fs-space-2); }
|
||
.procedure-add input { margin-top: 0; }
|
||
.procedure-add .btn-sm { flex: none; }
|
||
/* --fs-warning-fg, not --fs-warning: the token set draws the distinction
|
||
between the warning HUE and warning text, and this is text. */
|
||
.trigger-warning {
|
||
margin: -0.35rem 0 0.6rem; font-size: 0.78rem; line-height: 1.45;
|
||
color: var(--fs-warning-fg);
|
||
}
|
||
.field-note { margin: 0.5rem 0 0; font-size: 0.78rem; color: var(--fs-text-tertiary); line-height: 1.45; }
|
||
|
||
.relations h3 { margin: 0 0 0.5rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
|
||
.relations ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
|
||
.relation { display: flex; align-items: baseline; gap: 0.4rem; flex-wrap: wrap; font-size: 0.85rem; }
|
||
.relation-kind { color: var(--fs-accent); }
|
||
.relation-target { color: var(--fs-text-primary); }
|
||
.relation-note { width: 100%; font-size: 0.78rem; color: var(--fs-text-tertiary); }
|
||
|
||
/* A real base rule, not just descendants: the dangling-style check reads a
|
||
class that only ever appears as an ancestor as a half-deleted rule, and it
|
||
is right to — an element whose appearance comes only from its tag is one
|
||
`fieldset {}` edit away from being unstyled. */
|
||
.check { margin-bottom: 1rem; }
|
||
.check .intro { margin-top: 0; margin-bottom: 0.75rem; }
|
||
.check label { margin-bottom: 0.75rem; }
|
||
.stamp {
|
||
display: flex; align-items: center; gap: var(--fs-space-2);
|
||
flex-wrap: wrap;
|
||
margin-top: 0.25rem;
|
||
}
|
||
.stamp-age { font-size: 0.8rem; color: var(--fs-text-secondary); font-variant-numeric: tabular-nums; }
|
||
/* Never-checked is INFORMATION, not an error: it is the ordinary starting
|
||
state of every constraint anyone has just written. --fs-overdue (error red)
|
||
is reserved for a broken promise like a missed due date; a verification age
|
||
is not one, and colouring it that way would make a brand-new rule look
|
||
broken. Secondary text, weighted normally. */
|
||
.stamp-age.unchecked { color: var(--fs-text-tertiary); font-style: italic; }
|
||
.stamp-actions { display: flex; gap: var(--fs-space-2); margin-left: auto; }
|
||
.stamp-actions button {
|
||
cursor: pointer; font: inherit; font-size: 0.78rem;
|
||
background: var(--fs-surface-raised); color: var(--fs-text-primary);
|
||
border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-sm);
|
||
padding: 0.2rem 0.55rem;
|
||
}
|
||
.stamp-actions button:hover:not(:disabled) { background: var(--fs-surface-hover); }
|
||
.stamp-actions button:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
|
||
|
||
.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" />
|
||
<style src="@/assets/moments-shared.css" />
|