feat(moments): the human door onto mounts, mappings and per-moment telemetry (milestone 458 step 6, #4924)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Failing after 1m34s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 2m7s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Failing after 1m34s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 2m7s
Everything an agent can do with moments, a person can now see and change in the app. - Rule editor: a moment picker beside the trigger. Catalog moments are ticked; a named procedure's `skill.<name>` is typed and checked as the server checks it. `moments` is always sent, so unticking the last moment unmounts the rule. - Settings, Moments section (General tab): for each moment, what it means, the actions that reach it on this install (shipped ones can be switched off, the install's own removed), how many rules are mounted on it, and deliveries and agent opens over the window. Below that: named procedures with mounts, switched-off defaults with Restore, and a form to add an action. - retrieval_telemetry.moment_usage: per moment, `delivered`, `rules`, `opened` (agent pulls after the first delivery there; an upper bound, as by_source is) and `last_delivered_at`. No ratio, because a mount is a person's statement, not a ranker's guess. Guarded on its own, and also reported in retrieval_summary as `moment_usage`. - rulebooks.mount_counts; mounted_moments now derives from it. - GET /api/retrieval/moments carries `mounted` and `usage` (?days=). DELETE /moments/mappings also reads the mapping from query parameters, since the browser's DELETE sends no body. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useMomentsStore } from "@/stores/moments";
|
||||
import type { MomentAction, MomentUsage } from "@/api/moments";
|
||||
import { fmtDate } from "@/utils/dateFormat";
|
||||
|
||||
/**
|
||||
* The human door onto moments (milestone 458 step 6): every moment in the
|
||||
* catalog, the actions that reach it on this install, how many rules ride on
|
||||
* it, and what reaching it has delivered. The session's own door is
|
||||
* `list_moments` / `map_action` / `unmap_action`, and both call the same
|
||||
* services — this view exists so a person can see and change everything an
|
||||
* agent can, without asking one to.
|
||||
*/
|
||||
const store = useMomentsStore();
|
||||
|
||||
const data = computed(() => store.data);
|
||||
const catalog = computed(() => data.value?.moments ?? []);
|
||||
const days = computed(() => data.value?.usage.days ?? 30);
|
||||
const usageFailed = computed(() => !!data.value?.usage.moment_usage_failed);
|
||||
|
||||
function actionsFor(moment: string): MomentAction[] {
|
||||
return data.value?.actions[moment] ?? [];
|
||||
}
|
||||
function mounted(moment: string): number {
|
||||
return data.value?.mounted[moment] ?? 0;
|
||||
}
|
||||
function usage(moment: string): MomentUsage | null {
|
||||
return data.value?.usage.by_moment[moment] ?? null;
|
||||
}
|
||||
|
||||
// A named procedure is its own moment (`skill.<name>`), so it appears here
|
||||
// only once something is mounted on it or has been delivered at it — the
|
||||
// catalog cannot list procedures it has never heard of.
|
||||
const procedureMoments = computed(() => {
|
||||
const names = new Set<string>([
|
||||
...Object.keys(data.value?.mounted ?? {}),
|
||||
...Object.keys(data.value?.usage.by_moment ?? {}),
|
||||
]);
|
||||
return [...names].filter((n) => n.startsWith("skill.")).sort();
|
||||
});
|
||||
|
||||
function actionLabel(a: { tool: string; match: string }): string {
|
||||
return a.match ? `${a.tool} · ${a.match}` : a.tool;
|
||||
}
|
||||
|
||||
const busy = ref(false);
|
||||
|
||||
async function run(fn: () => Promise<unknown>) {
|
||||
busy.value = true;
|
||||
try {
|
||||
await fn();
|
||||
} catch {
|
||||
// The store has already said why; the row stays as it was.
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeAction(moment: string, a: MomentAction) {
|
||||
// A shipped default is switched off rather than deleted, and comes back
|
||||
// with "Restore" below — worth a confirm, since every later session on
|
||||
// this install loses the moment for that action.
|
||||
if (a.via === "default"
|
||||
&& !confirm(`Stop ${actionLabel(a)} reaching ${moment} on this install? You can restore it below.`)) {
|
||||
return;
|
||||
}
|
||||
void run(() => store.unmap({ tool: a.tool, match: a.match, moment, reason: "Removed in Settings." }));
|
||||
}
|
||||
|
||||
function restore(r: { tool: string; match: string; moment: string }) {
|
||||
void run(() => store.map({ tool: r.tool, match: r.match, moment: r.moment }));
|
||||
}
|
||||
|
||||
// ── Add a mapping ─────────────────────────────────────────────────────────
|
||||
const draftTool = ref("");
|
||||
const draftMatch = ref("");
|
||||
const draftMoment = ref("");
|
||||
const draftReason = ref("");
|
||||
const canAdd = computed(() => !!draftTool.value.trim() && !!draftMoment.value && !busy.value);
|
||||
|
||||
async function addMapping() {
|
||||
if (!canAdd.value) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
await store.map({
|
||||
tool: draftTool.value.trim(),
|
||||
match: draftMatch.value.trim(),
|
||||
moment: draftMoment.value,
|
||||
reason: draftReason.value.trim(),
|
||||
});
|
||||
draftTool.value = "";
|
||||
draftMatch.value = "";
|
||||
draftReason.value = "";
|
||||
} catch {
|
||||
// Kept as typed, so the refusal the store showed can be acted on.
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => store.fetch(true));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="moments-settings">
|
||||
<p v-if="store.loading && !data" class="empty-msg">Loading moments…</p>
|
||||
<p v-else-if="store.failed && !data" class="error-msg">
|
||||
The moment catalog could not be loaded.
|
||||
<button type="button" class="btn-text" @click="store.fetch(true)">Try again</button>
|
||||
</p>
|
||||
|
||||
<template v-else-if="data">
|
||||
<p class="window-note">
|
||||
Deliveries and opens over the last {{ days }} days. “Opened” counts the rules an agent
|
||||
went on to read — an upper bound, since a rule delivered at two moments and read once
|
||||
counts at both.
|
||||
</p>
|
||||
<p v-if="usageFailed" class="error-msg">
|
||||
The delivery counts could not be read, so they are missing below — not zero.
|
||||
</p>
|
||||
<p v-if="data.mounted_failed" class="error-msg">
|
||||
The mount counts could not be read, so they are missing below — not zero.
|
||||
</p>
|
||||
|
||||
<ul class="moment-list">
|
||||
<li v-for="m in catalog" :key="m.name" class="moment-row">
|
||||
<div class="moment-head">
|
||||
<code class="moment-name">{{ m.name }}</code>
|
||||
<span class="moment-stats">
|
||||
<span :class="{ 'stat-none': !mounted(m.name) }">
|
||||
{{ mounted(m.name) ? `${mounted(m.name)} ${mounted(m.name) === 1 ? "rule" : "rules"} mounted` : "nothing mounted" }}
|
||||
</span>
|
||||
<template v-if="usage(m.name)">
|
||||
<span>delivered {{ usage(m.name)!.delivered }}</span>
|
||||
<span>opened {{ usage(m.name)!.opened }} of {{ usage(m.name)!.rules }}</span>
|
||||
<span v-if="usage(m.name)!.last_delivered_at" class="stat-when">
|
||||
last {{ fmtDate(usage(m.name)!.last_delivered_at!) }}
|
||||
</span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<p class="moment-desc">{{ m.means }}</p>
|
||||
<div class="moment-actions">
|
||||
<span class="actions-label">Reached by</span>
|
||||
<span v-if="!actionsFor(m.name).length" class="stat-none">
|
||||
no action here — {{ m.reached_by }}
|
||||
</span>
|
||||
<span
|
||||
v-for="a in actionsFor(m.name)"
|
||||
:key="`${a.via}:${a.tool}:${a.match}`"
|
||||
:class="['action-chip', { 'is-install': a.via === 'install' }]"
|
||||
:title="a.via === 'install' ? 'Added on this install' : 'Ships with Scribe'"
|
||||
>
|
||||
<code>{{ actionLabel(a) }}</code>
|
||||
<button
|
||||
type="button"
|
||||
class="chip-remove"
|
||||
:disabled="busy"
|
||||
:aria-label="a.via === 'install'
|
||||
? `Remove ${actionLabel(a)} from ${m.name}`
|
||||
: `Switch off the shipped ${actionLabel(a)} for ${m.name}`"
|
||||
@click="removeAction(m.name, a)"
|
||||
>×</button>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<template v-if="procedureMoments.length">
|
||||
<h3 class="sub-title">Named procedures</h3>
|
||||
<ul class="moment-list">
|
||||
<li v-for="name in procedureMoments" :key="name" class="moment-row">
|
||||
<div class="moment-head">
|
||||
<code class="moment-name">{{ name }}</code>
|
||||
<span class="moment-stats">
|
||||
<span :class="{ 'stat-none': !mounted(name) }">
|
||||
{{ mounted(name) ? `${mounted(name)} ${mounted(name) === 1 ? "rule" : "rules"} mounted` : "nothing mounted" }}
|
||||
</span>
|
||||
<template v-if="usage(name)">
|
||||
<span>delivered {{ usage(name)!.delivered }}</span>
|
||||
<span>opened {{ usage(name)!.opened }} of {{ usage(name)!.rules }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<template v-if="data.removed_defaults.length">
|
||||
<h3 class="sub-title">Shipped mappings switched off here</h3>
|
||||
<ul class="removed-list">
|
||||
<li v-for="r in data.removed_defaults" :key="r.id" class="removed-row">
|
||||
<code>{{ actionLabel(r) }}</code>
|
||||
<span class="removed-arrow">→</span>
|
||||
<code>{{ r.moment }}</code>
|
||||
<span v-if="r.reason" class="removed-reason">{{ r.reason }}</span>
|
||||
<button type="button" class="btn-text" :disabled="busy" @click="restore(r)">Restore</button>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<form class="add-mapping" @submit.prevent="addMapping">
|
||||
<h3 class="sub-title">Add an action</h3>
|
||||
<p class="field-hint">
|
||||
When something you do plainly happens at a moment and nothing fired — your own deploy
|
||||
script, a tool from another MCP server. For a command tool, the match is how the command
|
||||
starts (<code>make ship</code>); for any other tool, its arguments as
|
||||
<code>field=value</code> pairs. Leave it empty to map every call.
|
||||
</p>
|
||||
<div class="add-grid">
|
||||
<label>
|
||||
Tool
|
||||
<input v-model="draftTool" class="fs-input" placeholder="Bash, or an MCP tool's name" />
|
||||
</label>
|
||||
<label>
|
||||
Match
|
||||
<input v-model="draftMatch" class="fs-input" placeholder="make ship · status=done · empty for every call" />
|
||||
</label>
|
||||
<label>
|
||||
Moment
|
||||
<select v-model="draftMoment" class="fs-input">
|
||||
<option value="" disabled>Choose a moment</option>
|
||||
<option v-for="m in catalog" :key="m.name" :value="m.name">{{ m.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Why <span class="optional">(optional)</span>
|
||||
<input v-model="draftReason" class="fs-input" placeholder="what it is for here" />
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary" :disabled="!canAdd">Add action</button>
|
||||
</form>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.moments-settings { display: flex; flex-direction: column; gap: var(--fs-space-3); }
|
||||
.window-note { margin: 0; font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); line-height: var(--fs-leading-body); }
|
||||
|
||||
.moment-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-2); }
|
||||
.moment-row {
|
||||
padding: var(--fs-space-3);
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-md);
|
||||
}
|
||||
.moment-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--fs-space-2); }
|
||||
.moment-head .moment-name { font-size: var(--fs-size-body-sm); }
|
||||
.moment-stats {
|
||||
margin-left: auto;
|
||||
display: flex; flex-wrap: wrap; gap: var(--fs-space-3);
|
||||
font-size: var(--fs-size-tiny); color: var(--fs-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* Nothing mounted is the ordinary state of most moments, not a fault. */
|
||||
.stat-none { color: var(--fs-text-tertiary); font-style: italic; }
|
||||
.stat-when { color: var(--fs-text-tertiary); }
|
||||
.moment-desc { margin: var(--fs-space-1) 0 var(--fs-space-2); font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); line-height: var(--fs-leading-body); }
|
||||
|
||||
.moment-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--fs-space-2); font-size: var(--fs-size-tiny); }
|
||||
.actions-label { color: var(--fs-text-tertiary); text-transform: uppercase; letter-spacing: var(--fs-tracking-tiny); }
|
||||
.action-chip {
|
||||
display: inline-flex; align-items: center; gap: var(--fs-space-1);
|
||||
padding: 0.05rem 0.45rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
.action-chip code { font-family: var(--fs-font-mono); }
|
||||
/* This install's own, set apart from the shipped ones the way the tuning
|
||||
trail marks the operator's changes: a dashed edge, no accent. */
|
||||
.action-chip.is-install { border-style: dashed; color: var(--fs-text-primary); }
|
||||
|
||||
.sub-title { margin: var(--fs-space-3) 0 var(--fs-space-1); font-size: 0.95rem; color: var(--fs-text-primary); }
|
||||
|
||||
.removed-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-1); }
|
||||
.removed-row { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--fs-space-2); font-size: var(--fs-size-body-sm); }
|
||||
.removed-row code { font-family: var(--fs-font-mono); color: var(--fs-text-secondary); text-decoration: line-through; }
|
||||
.removed-arrow { color: var(--fs-text-tertiary); }
|
||||
.removed-reason { color: var(--fs-text-tertiary); font-size: var(--fs-size-tiny); }
|
||||
|
||||
.add-mapping { display: flex; flex-direction: column; gap: var(--fs-space-2); align-items: flex-start; }
|
||||
.add-grid {
|
||||
width: 100%;
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||
gap: var(--fs-space-2) var(--fs-space-3);
|
||||
}
|
||||
.add-grid label { display: flex; flex-direction: column; gap: var(--fs-space-1); font-size: var(--fs-size-body-sm); color: var(--fs-text-primary); }
|
||||
.optional { color: var(--fs-text-tertiary); font-weight: normal; }
|
||||
</style>
|
||||
|
||||
<style src="@/assets/moments-shared.css" />
|
||||
@@ -2,6 +2,7 @@
|
||||
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";
|
||||
@@ -11,6 +12,7 @@ const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
const store = useRulebooksStore();
|
||||
const canon = useCanonicalSystemsStore();
|
||||
const momentsStore = useMomentsStore();
|
||||
const title = ref("");
|
||||
const statement = ref("");
|
||||
const whenToApply = ref("");
|
||||
@@ -20,6 +22,18 @@ const whenToApply = ref("");
|
||||
// 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("");
|
||||
@@ -46,6 +60,26 @@ 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);
|
||||
@@ -85,6 +119,7 @@ async function load() {
|
||||
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 || "";
|
||||
@@ -96,12 +131,15 @@ async function load() {
|
||||
whenToApply.value = "";
|
||||
kind.value = "rule";
|
||||
systemIds.value = [];
|
||||
ruleMoments.value = [];
|
||||
why.value = "";
|
||||
howToApply.value = "";
|
||||
verifyWith.value = "";
|
||||
expiresWhen.value = "";
|
||||
}
|
||||
await canon.fetchCatalog();
|
||||
procedureDraft.value = "";
|
||||
procedureError.value = "";
|
||||
await Promise.all([canon.fetchCatalog(), momentsStore.fetch()]);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
@@ -117,6 +155,9 @@ async function save() {
|
||||
// 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
|
||||
@@ -210,6 +251,54 @@ watch(() => props.ruleId, load);
|
||||
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">
|
||||
@@ -370,6 +459,17 @@ 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 {
|
||||
@@ -422,3 +522,4 @@ legend { padding: 0 0.35rem; font-size: 0.8rem; color: var(--fs-text-tertiary);
|
||||
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" />
|
||||
|
||||
Reference in New Issue
Block a user