CI & Build / Python lint (push) Successful in 9s
CI & Build / Plugin hooks (push) Successful in 22s
CI & Build / TypeScript typecheck (push) Successful in 1m0s
CI & Build / integration (push) Successful in 1m55s
CI & Build / Python tests (push) Successful in 2m20s
CI & Build / Build & push image (push) Successful in 1m7s
Components, stores, api clients, router, types and shared CSS now state what each piece is and why, without the milestone that introduced it. diff.ts names its consumers instead of the copy history; stores/notes and tasks state their current scope instead of the cleanup that set it; theme.css states the token practice instead of the sweep that established it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
579 lines
24 KiB
Vue
579 lines
24 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 { apiErrorMessage } from "@/api/client";
|
||
import { listRulebooks, listTopics, type Rule, type RuleKind, type RulePreset } from "@/api/rulebooks";
|
||
|
||
const props = defineProps<{
|
||
ruleId: number | null;
|
||
topicId: number | null;
|
||
preset?: RulePreset | 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. 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: 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 = props.preset?.whenToApply ?? "";
|
||
kind.value = props.preset?.kind ?? "rule";
|
||
systemIds.value = [];
|
||
ruleMoments.value = [...(props.preset?.moments ?? [])];
|
||
why.value = "";
|
||
howToApply.value = "";
|
||
verifyWith.value = "";
|
||
expiresWhen.value = "";
|
||
}
|
||
procedureDraft.value = "";
|
||
procedureError.value = "";
|
||
await Promise.all([canon.fetchCatalog(), momentsStore.load(), loadHomes()]);
|
||
}
|
||
|
||
// ── Where a new record lives, when the opener did not say ──────────────────
|
||
// The rules view opens the editor inside a topic; Settings does not, so a new
|
||
// record opened there asks for its home. A rulebook topic is global — it
|
||
// applies in every project, as a reply shape does.
|
||
const homeChoices = ref<{ id: number; label: string }[]>([]);
|
||
const chosenTopic = ref<number | null>(null);
|
||
const homeError = ref("");
|
||
const needsHome = computed(() => isCreating.value && props.topicId === null);
|
||
|
||
async function loadHomes() {
|
||
if (!needsHome.value || homeChoices.value.length) return;
|
||
try {
|
||
const books = await listRulebooks();
|
||
const perBook = await Promise.all(books.map(async (rb) => {
|
||
const ts = await listTopics(rb.id);
|
||
return ts.map((t) => ({ id: t.id, label: `${rb.title} › ${t.title}` }));
|
||
}));
|
||
homeChoices.value = perBook.flat();
|
||
if (homeChoices.value.length === 1) chosenTopic.value = homeChoices.value[0].id;
|
||
} catch (e: unknown) {
|
||
homeError.value = apiErrorMessage(e, "Could not load where this could live");
|
||
}
|
||
}
|
||
|
||
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,
|
||
};
|
||
const home = props.topicId ?? chosenTopic.value;
|
||
if (isCreating.value && home === null) {
|
||
// Written but homeless: keep the draft open rather than lose it on close.
|
||
homeError.value = "Choose where it lives before saving.";
|
||
return;
|
||
}
|
||
if (isCreating.value && home !== null) {
|
||
await store.createRule(home, 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 v-if="needsHome">
|
||
Where it lives
|
||
<select v-model="chosenTopic" class="fs-input">
|
||
<option :value="null" disabled>Choose a rulebook topic</option>
|
||
<option v-for="h in homeChoices" :key="h.id" :value="h.id">{{ h.label }}</option>
|
||
</select>
|
||
</label>
|
||
<p v-if="homeError" class="home-error" role="alert">{{ homeError }}</p>
|
||
<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>
|
||
|
||
<!-- `.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).
|
||
Block 0, as in every pane that loads it: a shared src must sit at
|
||
one index everywhere (tests/test_frontend_shared_styles.py). -->
|
||
<style src="@/assets/rules-shared.css" />
|
||
<style scoped>
|
||
.home-error { margin: 0 0 var(--fs-space-2); color: var(--fs-error); font-size: var(--fs-size-body-sm); }
|
||
.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: 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>
|
||
|
||
<!-- An @import, not a second <style src>: plugin-vue caches ONE descriptor per
|
||
src file, and every other importer of moments-shared.css has it as style
|
||
block 1. Here it would be block 2, so whichever SFC registered last decided
|
||
the lookup and the production build failed with "reading 'scoped'" when
|
||
the transform order changed (tests/test_frontend_shared_styles.py). -->
|
||
<style>
|
||
@import "@/assets/moments-shared.css";
|
||
</style>
|