CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m40s
CI & Build / Python tests (push) Successful in 2m37s
CI & Build / Build & push image (push) Successful in 56s
Run 8271 failed in the image build: "[vite:vue] Cannot read properties of undefined (reading 'scoped')" on moments-shared.css. plugin-vue caches one descriptor per src file and answers ?index=N from whichever component registered it last. RuleEditorSlideOver had moments-shared at block 2, while its other two importers have it at block 1. Step 2's new imports changed the transform order and exposed the bug. - RuleEditorSlideOver: moments-shared is now loaded with an @import instead of a third <style src>. - rules-shared.css sat at block 0 in the five panes and at block 1 in LessonDetailView, LessonEditorView and RuleEditorSlideOver, which is the same latent hazard. The three move to block 0. - tests/test_frontend_shared_styles.py: a guard that every <style src> sheet sits at one index, plus a test that the guard can fail. vue-tsc cannot see this hazard, and vite build only runs after every test has passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
614 lines
21 KiB
Vue
614 lines
21 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* Write or edit a LESSON — a transferable insight, found by the situation it
|
|
* applies to rather than by its topic.
|
|
*
|
|
* THREE FIELDS, NOT ONE MARKDOWN BOX. The title and body are composed by the
|
|
* service from `what`, `when_to_apply` and `insight`; this form never sends a
|
|
* document. That is the design milestone 385 step 1 settled on, and the
|
|
* evidence for it is blunt: the snippet corpus carries a trigger on every
|
|
* record with no guard anywhere, because a service composes the title from a
|
|
* named parameter. What is at 100% is a named structured field — not a writer
|
|
* remembering a convention.
|
|
*
|
|
* THE TRIGGER IS THE FIELD THAT CANNOT BE MISSED, so it is given the most
|
|
* room, its own explanation, and a save button that refuses without it. A
|
|
* lesson with no trigger saves, reads correctly in every listing, and never
|
|
* surfaces — and there is nothing to notice afterwards, because it looks
|
|
* exactly like a lesson that works. The form is where that gets caught.
|
|
*
|
|
* WHICH RULE IS THIS AN INSTANCE OF (milestone 440) is asked here, while the
|
|
* writer still has the situation in mind, the same way the create tool asks
|
|
* it. There are three answers: the rule(s), "no rule fits" with a reason, or
|
|
* leave it open. None is forced, because a lesson left open is still a
|
|
* lesson. But the form offers the rules it resembles before the save, so
|
|
* naming one costs a click.
|
|
*/
|
|
import { computed, onMounted, ref, watch } from "vue";
|
|
import { useRoute, useRouter } from "vue-router";
|
|
|
|
import { apiErrorMessage } from "@/api/client";
|
|
import {
|
|
createLesson,
|
|
getLesson,
|
|
ruleCandidates,
|
|
updateLesson,
|
|
type Lesson,
|
|
type LessonPayload,
|
|
type RuleCandidate,
|
|
} from "@/api/lessons";
|
|
import type { RuleKind } from "@/api/rulebooks";
|
|
import ProjectSelector from "@/components/ProjectSelector.vue";
|
|
import TagInput from "@/components/TagInput.vue";
|
|
import { useNotesStore } from "@/stores/notes";
|
|
import { useToastStore } from "@/stores/toast";
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const toast = useToastStore();
|
|
const notesStore = useNotesStore();
|
|
|
|
const lessonId = computed(() => {
|
|
const raw = route.params.id;
|
|
return raw ? Number(raw) : null;
|
|
});
|
|
const isEdit = computed(() => lessonId.value !== null);
|
|
|
|
const what = ref("");
|
|
const whenToApply = ref("");
|
|
const insight = ref("");
|
|
const tags = ref<string[]>([]);
|
|
const projectId = ref<number | null>(null);
|
|
const learnedFrom = ref<number[]>([]);
|
|
|
|
const loading = ref(false);
|
|
const saving = ref(false);
|
|
const error = ref<string | null>(null);
|
|
// The near-duplicate gate's answer, held so the writer can read it and then
|
|
// decide — rather than being silently overridden or silently blocked.
|
|
const duplicate = ref<{ id: number; title: string } | null>(null);
|
|
|
|
/** The composed title, shown live. The writer is agreeing to a document they
|
|
* can see, which is the same reason `create_rule` shows a rule's statement
|
|
* before asking for a yes. */
|
|
const previewTitle = computed(() => {
|
|
const subject = what.value.trim();
|
|
const trigger = whenToApply.value.trim();
|
|
if (subject && trigger) return `${subject} — ${trigger}`;
|
|
return subject || trigger;
|
|
});
|
|
|
|
// ── which rule is this an instance of ──────────────────────────────────────
|
|
|
|
type Answer = "rules" | "no_rule" | "open";
|
|
const answer = ref<Answer>("open");
|
|
const selectedRuleIds = ref<number[]>([]);
|
|
const noRuleWhy = ref("");
|
|
/** The answer the lesson held when loaded. An edit sends an answer only when
|
|
* it changed: re-sending the same rules would re-stamp their judgments, and
|
|
* a shared editor who cannot see the owner's rule would reject it by
|
|
* re-sending a list without it. */
|
|
const original = ref<{ answer: Answer; ruleIds: number[]; why: string }>({
|
|
answer: "open", ruleIds: [], why: "",
|
|
});
|
|
/** The rules already linked, kept in the list so they can be unticked even
|
|
* when the search no longer ranks them. */
|
|
const linkedRules = ref<{ id: number; title: string; kind: RuleKind }[]>([]);
|
|
/** null until a search has answered. */
|
|
const candidates = ref<RuleCandidate[] | null>(null);
|
|
const searchUnavailable = ref(false);
|
|
const searching = ref(false);
|
|
|
|
const ruleOptions = computed(() => {
|
|
const seen = new Set<number>();
|
|
const out: { id: number; title: string; kind: RuleKind; when_to_apply?: string }[] = [];
|
|
for (const r of [...linkedRules.value, ...(candidates.value ?? [])]) {
|
|
if (seen.has(r.id)) continue;
|
|
seen.add(r.id);
|
|
out.push(r);
|
|
}
|
|
return out;
|
|
});
|
|
|
|
/** The server has no way to take back "no rule fits" except by naming a
|
|
* rule, so once that is the answer, "leave it open" is not offered. */
|
|
const canLeaveOpen = computed(() => original.value.answer !== "no_rule");
|
|
|
|
function sameIds(a: number[], b: number[]): boolean {
|
|
if (a.length !== b.length) return false;
|
|
const want = new Set(b);
|
|
return a.every((id) => want.has(id));
|
|
}
|
|
|
|
const answerChanged = computed(() => {
|
|
const o = original.value;
|
|
if (answer.value !== o.answer) return true;
|
|
if (answer.value === "rules") return !sameIds(selectedRuleIds.value, o.ruleIds);
|
|
if (answer.value === "no_rule") return noRuleWhy.value.trim() !== o.why;
|
|
return false;
|
|
});
|
|
|
|
/** Why the answer as it stands cannot be saved, or null when it can. */
|
|
const answerProblem = computed(() => {
|
|
if (!answerChanged.value) return null;
|
|
if (answer.value === "rules" && !selectedRuleIds.value.length) {
|
|
return "Tick the rule this is an instance of, or choose another answer.";
|
|
}
|
|
if (answer.value === "no_rule" && !noRuleWhy.value.trim()) {
|
|
return "Say why no rule fits. A bare “none” cannot be judged again when a rule is later written for this situation.";
|
|
}
|
|
return null;
|
|
});
|
|
|
|
/** The answer's half of the payload. Empty when nothing changed. */
|
|
function answerPayload(): Pick<LessonPayload, "rule_ids" | "no_rule"> {
|
|
if (!answerChanged.value) return {};
|
|
if (answer.value === "rules") return { rule_ids: selectedRuleIds.value };
|
|
if (answer.value === "no_rule") return { no_rule: noRuleWhy.value.trim() };
|
|
// Left open after being linked: an empty set rejects the linked rules.
|
|
return original.value.answer === "rules" ? { rule_ids: [] } : {};
|
|
}
|
|
|
|
async function findRules() {
|
|
if (!what.value.trim() && !whenToApply.value.trim()) return;
|
|
searching.value = true;
|
|
try {
|
|
const res = await ruleCandidates({
|
|
what: what.value.trim(),
|
|
when_to_apply: whenToApply.value.trim(),
|
|
project_id: projectId.value,
|
|
});
|
|
candidates.value = res.candidates ?? [];
|
|
searchUnavailable.value = res.candidates === null;
|
|
} catch (e) {
|
|
toast.show(apiErrorMessage(e, "Failed to look for rules"), "error");
|
|
} finally {
|
|
searching.value = false;
|
|
}
|
|
}
|
|
|
|
// Search the first time the writer says it is an instance of a rule, including
|
|
// when an edit opens on a linked lesson.
|
|
watch(answer, (a) => {
|
|
if (a === "rules" && candidates.value === null && !searching.value) findRules();
|
|
});
|
|
|
|
const canSave = computed(
|
|
() =>
|
|
what.value.trim().length > 0 &&
|
|
whenToApply.value.trim().length > 0 &&
|
|
answerProblem.value === null,
|
|
);
|
|
|
|
async function load() {
|
|
if (!isEdit.value || lessonId.value === null) return;
|
|
loading.value = true;
|
|
error.value = null;
|
|
try {
|
|
const lesson: Lesson = await getLesson(lessonId.value);
|
|
what.value = lesson.what;
|
|
whenToApply.value = lesson.when_to_apply;
|
|
// The insight WITHOUT the composed lines, so saving doesn't accumulate a
|
|
// copy of the trigger line on every edit.
|
|
insight.value = lesson.insight;
|
|
tags.value = lesson.tags ?? [];
|
|
projectId.value = lesson.project_id;
|
|
learnedFrom.value = lesson.learned_from ?? [];
|
|
const confirmed = (lesson.rules ?? []).filter((r) => r.state === "confirmed");
|
|
linkedRules.value = confirmed;
|
|
const ids = confirmed.map((r) => r.id);
|
|
const why = lesson.no_rule?.why ?? "";
|
|
const held: Answer =
|
|
lesson.rule_judgment === "linked" ? "rules"
|
|
: lesson.rule_judgment === "no_rule" ? "no_rule"
|
|
: "open";
|
|
original.value = { answer: held, ruleIds: ids, why };
|
|
selectedRuleIds.value = [...ids];
|
|
noRuleWhy.value = why;
|
|
answer.value = held;
|
|
} catch (e) {
|
|
error.value = apiErrorMessage(e, "Failed to load this lesson");
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function save(force = false) {
|
|
if (!canSave.value || saving.value) return;
|
|
saving.value = true;
|
|
error.value = null;
|
|
duplicate.value = null;
|
|
try {
|
|
const payload = {
|
|
what: what.value.trim(),
|
|
when_to_apply: whenToApply.value.trim(),
|
|
insight: insight.value,
|
|
tags: tags.value,
|
|
learned_from: learnedFrom.value,
|
|
project_id: projectId.value,
|
|
...answerPayload(),
|
|
...(force ? { force: true } : {}),
|
|
};
|
|
const saved = isEdit.value && lessonId.value !== null
|
|
? await updateLesson(lessonId.value, payload)
|
|
: await createLesson(payload);
|
|
toast.show(isEdit.value ? "Lesson updated" : "Lesson recorded");
|
|
// "No rule fits" just completed a group of lessons in one situation (#4634).
|
|
// Said once, here, because the page this goes to does not recompute it.
|
|
if (saved.convergence) {
|
|
toast.show(
|
|
`${saved.convergence.lessons.length} lessons now say no rule fits this ` +
|
|
"situation. A situation met this often may want a rule of its own.",
|
|
"warning",
|
|
);
|
|
}
|
|
router.push(`/lessons/${saved.id}`);
|
|
} catch (e) {
|
|
// A 409 is the duplicate gate, not a failure: it hands back the record
|
|
// that already covers this moment so the writer can improve that one
|
|
// instead of standing a second beside it.
|
|
const body = (e as { status?: number; body?: Record<string, unknown> });
|
|
if (body?.status === 409 && body.body) {
|
|
const existing = body.body as { id?: number; title?: string };
|
|
if (existing.id) {
|
|
duplicate.value = { id: existing.id, title: existing.title ?? "" };
|
|
return;
|
|
}
|
|
}
|
|
error.value = apiErrorMessage(e, "Failed to save this lesson");
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
watch(lessonId, load);
|
|
onMounted(() => {
|
|
load();
|
|
// Pre-fill from the link that brought you here, the same convention the
|
|
// snippet editor uses. Left null it is a lesson with no recorded origin,
|
|
// which is valid — `project_id` says where a lesson was LEARNED and was
|
|
// never the limit on where it can be found (step 3).
|
|
if (!isEdit.value && route.query.projectId) {
|
|
projectId.value = Number(route.query.projectId);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="lesson-editor">
|
|
<header class="le-head">
|
|
<h1>{{ isEdit ? "Edit lesson" : "Record a lesson" }}</h1>
|
|
<p class="le-sub">
|
|
Something worth knowing, kept so a later session meets it at the moment
|
|
it applies. A lesson binds nobody.
|
|
</p>
|
|
</header>
|
|
|
|
<p v-if="error" class="le-error" role="alert">{{ error }}</p>
|
|
|
|
<div v-if="duplicate" class="le-dupe" role="alert">
|
|
<p>
|
|
<strong>A lesson already covers this moment.</strong>
|
|
Improving that one keeps what was learned in a single place — two
|
|
lessons under one trigger compete for the same slot, so the second
|
|
displaces the first rather than adding to it.
|
|
</p>
|
|
<div class="le-dupe-actions">
|
|
<router-link :to="`/lessons/${duplicate.id}`" class="le-link">
|
|
Open “{{ duplicate.title }}”
|
|
</router-link>
|
|
<button type="button" class="le-ghost" @click="save(true)">
|
|
Record it anyway
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<p v-if="loading" class="le-muted">Loading…</p>
|
|
|
|
<form v-else class="le-form" @submit.prevent="save()">
|
|
<!-- The trigger comes FIRST, before the claim. It is what makes a lesson
|
|
findable, and putting it second invites it to be treated as an
|
|
afterthought to the thing the writer arrived wanting to say. -->
|
|
<label class="le-field le-field--primary">
|
|
<span class="le-label">When does this apply?</span>
|
|
<span class="le-hint">
|
|
The situation, in the words it will present itself in — what someone
|
|
would be seeing, saying, or about to do. “A test fails on code you
|
|
believe is correct” is a trigger. “Testing” is a topic, and a topic
|
|
matches everything and surfaces for nothing.
|
|
</span>
|
|
<textarea
|
|
v-model="whenToApply"
|
|
class="le-input le-textarea"
|
|
rows="3"
|
|
required
|
|
placeholder="a CI run has sat in_progress far longer than its suite takes"
|
|
/>
|
|
</label>
|
|
|
|
<label class="le-field">
|
|
<span class="le-label">What did you learn?</span>
|
|
<span class="le-hint">The claim itself, in one line, as you would say it.</span>
|
|
<input
|
|
v-model="what"
|
|
class="le-input"
|
|
type="text"
|
|
required
|
|
placeholder="Read the job log before waiting longer"
|
|
/>
|
|
</label>
|
|
|
|
<label class="le-field">
|
|
<span class="le-label">The detail <em>(optional)</em></span>
|
|
<span class="le-hint">
|
|
What you would want handed to you in the same situation next time —
|
|
the evidence, the reasoning, the thing that is not obvious.
|
|
</span>
|
|
<textarea
|
|
v-model="insight"
|
|
class="le-input le-textarea le-textarea--tall"
|
|
rows="10"
|
|
/>
|
|
</label>
|
|
|
|
<label class="le-field">
|
|
<span class="le-label">Tags</span>
|
|
<TagInput
|
|
v-model="tags"
|
|
:fetchTags="(q: string) => notesStore.fetchAllTags(q)"
|
|
/>
|
|
</label>
|
|
|
|
<label class="le-field">
|
|
<span class="le-label">Learned on <em>(optional)</em></span>
|
|
<span class="le-hint">
|
|
Where this was learned. Kept as a fact about its origin — a lesson is
|
|
retrievable from every project regardless, which is the point of the
|
|
kind.
|
|
</span>
|
|
<ProjectSelector v-model="projectId" />
|
|
</label>
|
|
|
|
<fieldset class="le-field le-answer">
|
|
<legend class="le-label">Which rule is this an instance of?</legend>
|
|
<span class="le-hint">
|
|
A confirmed rule surfaces through this lesson whenever its situation
|
|
comes up. Saying no rule fits is an answer too: lessons that keep
|
|
landing in one situation with no rule are how a missing rule gets
|
|
noticed.
|
|
</span>
|
|
|
|
<label class="le-choice">
|
|
<input v-model="answer" type="radio" value="rules" />
|
|
An instance of a rule
|
|
</label>
|
|
<div v-if="answer === 'rules'" class="le-rules">
|
|
<p v-if="searching" class="le-muted">Looking for rules it resembles…</p>
|
|
<p v-else-if="searchUnavailable" class="le-muted">
|
|
Rule search is unavailable right now.
|
|
</p>
|
|
<p
|
|
v-else-if="candidates !== null && !ruleOptions.length"
|
|
class="le-muted"
|
|
>
|
|
No rule resembles this lesson closely. If none fits, say so below.
|
|
</p>
|
|
<label v-for="r in ruleOptions" :key="r.id" class="le-choice le-rule">
|
|
<input v-model="selectedRuleIds" type="checkbox" :value="r.id" />
|
|
<span>
|
|
{{ r.title }}
|
|
<span v-if="r.kind === 'preference'" class="rule-chip rule-chip-preference">
|
|
preference
|
|
</span>
|
|
<span v-if="r.when_to_apply" class="le-hint le-rule-when">
|
|
{{ r.when_to_apply }}
|
|
</span>
|
|
</span>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
class="le-ghost le-small"
|
|
:disabled="searching || (!what.trim() && !whenToApply.trim())"
|
|
@click="findRules"
|
|
>
|
|
{{ candidates === null ? "Look for rules" : "Search again" }}
|
|
</button>
|
|
</div>
|
|
|
|
<label class="le-choice">
|
|
<input v-model="answer" type="radio" value="no_rule" />
|
|
No rule fits
|
|
</label>
|
|
<textarea
|
|
v-if="answer === 'no_rule'"
|
|
v-model="noRuleWhy"
|
|
class="le-input le-textarea le-why-input"
|
|
rows="2"
|
|
placeholder="Why none fits: a one-off of one host, a call with no single right answer…"
|
|
/>
|
|
|
|
<label v-if="canLeaveOpen" class="le-choice">
|
|
<input v-model="answer" type="radio" value="open" />
|
|
Leave it open for now
|
|
</label>
|
|
</fieldset>
|
|
|
|
<!-- The composed document, shown before saving. The writer is agreeing
|
|
to a title they can read, not to one assembled out of sight. -->
|
|
<div v-if="previewTitle" class="le-preview">
|
|
<span class="le-preview-label">Stored as</span>
|
|
<p class="le-preview-title">{{ previewTitle }}</p>
|
|
</div>
|
|
|
|
<div class="le-actions">
|
|
<button type="submit" class="le-primary" :disabled="!canSave || saving">
|
|
{{ saving ? "Saving…" : isEdit ? "Save lesson" : "Record lesson" }}
|
|
</button>
|
|
<button type="button" class="le-ghost" @click="router.back()">
|
|
Cancel
|
|
</button>
|
|
<!-- Says WHY it is disabled. A greyed button with no reason is the
|
|
thing that gets clicked repeatedly and then worked around. -->
|
|
<span v-if="answerProblem" class="le-muted le-why">{{ answerProblem }}</span>
|
|
<span v-else-if="!canSave" class="le-muted le-why">
|
|
A lesson needs both a trigger and a claim — without the trigger it
|
|
would save and never reach anyone.
|
|
</span>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- `.rule-chip-preference` marks a preference among the rules offered, as it
|
|
does everywhere else a rule's kind is shown.
|
|
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>
|
|
.lesson-editor {
|
|
max-width: 820px;
|
|
margin: 0 auto;
|
|
padding: var(--fs-layout-page-pad);
|
|
color: var(--fs-text-primary);
|
|
}
|
|
|
|
.le-head h1 {
|
|
margin: 0 0 0.25rem;
|
|
font-size: 1.35rem;
|
|
font-weight: 500;
|
|
}
|
|
.le-sub {
|
|
margin: 0 0 1.5rem;
|
|
color: var(--fs-text-secondary);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.le-form { display: flex; flex-direction: column; gap: 1.25rem; }
|
|
|
|
.le-field { display: flex; flex-direction: column; gap: 0.35rem; }
|
|
|
|
/* The trigger gets visible weight, because it is the field whose absence is
|
|
invisible afterwards. */
|
|
.le-field--primary {
|
|
padding: 1rem;
|
|
border: 1px solid var(--fs-border-color);
|
|
border-radius: var(--fs-radius-lg);
|
|
background: var(--fs-surface-raised);
|
|
}
|
|
|
|
.le-label { font-weight: 500; font-size: 0.9rem; }
|
|
.le-label em { font-style: normal; color: var(--fs-text-tertiary); font-weight: 400; }
|
|
.le-hint {
|
|
color: var(--fs-text-secondary);
|
|
font-size: 0.82rem;
|
|
line-height: 1.45;
|
|
}
|
|
|
|
.le-input {
|
|
width: 100%;
|
|
padding: 0.55rem 0.7rem;
|
|
border: 1px solid var(--fs-border-color);
|
|
border-radius: var(--fs-radius-sm);
|
|
background: var(--fs-surface-page);
|
|
color: var(--fs-text-primary);
|
|
font: inherit;
|
|
font-size: 0.92rem;
|
|
}
|
|
.le-input:focus-visible {
|
|
outline: 2px solid var(--fs-accent);
|
|
outline-offset: 1px;
|
|
}
|
|
.le-textarea { resize: vertical; line-height: 1.5; }
|
|
.le-textarea--tall { font-family: var(--fs-font-mono); font-size: 0.85rem; }
|
|
|
|
.le-preview {
|
|
padding: 0.7rem 0.9rem;
|
|
border-left: 2px solid var(--fs-accent);
|
|
background: var(--fs-surface-raised);
|
|
border-radius: var(--fs-radius-sm);
|
|
}
|
|
.le-preview-label {
|
|
display: block;
|
|
font-size: 0.72rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
color: var(--fs-text-tertiary);
|
|
}
|
|
.le-preview-title { margin: 0.2rem 0 0; font-size: 0.92rem; }
|
|
|
|
.le-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem; }
|
|
|
|
.le-primary {
|
|
padding: 0.5rem 1.1rem;
|
|
border: none;
|
|
border-radius: var(--fs-radius-sm);
|
|
background: var(--fs-accent);
|
|
color: var(--fs-accent-fg);
|
|
font: inherit;
|
|
font-size: 0.9rem;
|
|
cursor: pointer;
|
|
}
|
|
.le-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
|
|
.le-ghost {
|
|
padding: 0.5rem 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.9rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.le-muted { color: var(--fs-text-secondary); font-size: 0.85rem; }
|
|
.le-why { flex-basis: 100%; }
|
|
|
|
.le-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;
|
|
}
|
|
|
|
.le-dupe {
|
|
padding: 0.8rem 1rem;
|
|
margin-bottom: 1rem;
|
|
border: 1px solid var(--fs-border-color);
|
|
border-radius: var(--fs-radius-lg);
|
|
background: var(--fs-surface-raised);
|
|
font-size: 0.88rem;
|
|
}
|
|
.le-dupe p { margin: 0 0 0.6rem; line-height: 1.5; }
|
|
.le-dupe-actions { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: center; }
|
|
.le-link { color: var(--fs-accent); }
|
|
|
|
.le-answer {
|
|
margin: 0;
|
|
padding: var(--fs-space-4);
|
|
border: 1px solid var(--fs-border-color);
|
|
border-radius: var(--fs-radius-lg);
|
|
}
|
|
.le-answer legend { padding: 0 var(--fs-space-1); }
|
|
.le-choice {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: var(--fs-space-2);
|
|
font-size: 0.9rem;
|
|
cursor: pointer;
|
|
}
|
|
.le-choice input { accent-color: var(--fs-accent); }
|
|
.le-rules {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
gap: var(--fs-space-2);
|
|
padding-left: var(--fs-space-5);
|
|
}
|
|
.le-rule-when { display: block; }
|
|
.le-why-input { margin-left: var(--fs-space-5); width: calc(100% - var(--fs-space-5)); }
|
|
.le-small { padding: 0.2rem 0.7rem; font-size: var(--fs-size-label); }
|
|
.le-ghost:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
|
|
</style>
|