feat(rules): the check is editable, visible, and sweepable in the UI (#3098, milestone 312 step 4)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m13s
CI & Build / Build & push image (push) Successful in 37s
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m13s
CI & Build / Build & push image (push) Successful in 37s
Rule 27 — the milestone was backend-only until this. Four surfaces:
RULE EDITOR — verify_with and expires_when under a legend that asks the
actual question ("Can this rule go stale?") and says empty is the normal
answer, because most rules are decisions and a form that implies a missing
field would get them filled in out of tidiness. When the SAVED rule carries
a check, the stamp shows with Still true / No longer true beside it. The
stamp reads the stored value, not the draft: an unsaved edit to the textarea
has not been run against anything.
SWEEP PANE — its own surface, not a filter on the rule list. That list can
only ever show one topic of one rulebook, and a rule that has gone false
belongs to no one rulebook; filtering it would under-report, which is the
failure this whole surface exists to catch. Reached from the rulebook list,
below the rulebooks, because that is where you go to look at rules.
RULE ROWS — a chip only on rules carrying a check, so its presence is the
signal. PROJECT RULES TAB — the check shows beside `why` when a rule has
one, read-only: that tab is the project's view of what binds it.
NO AGE-GRADED COLOUR anywhere, deliberately. The sweep is already ordered by
urgency, so a red/amber ramp would restate the ordering AND require an
invented "stale after N days" threshold — a magic number nobody could defend
and the first thing to go out of date. --fs-overdue is error red and reserved
for a broken promise like a missed due date; a verification age is not one,
and colouring it that way makes a rule someone just wrote look broken. Only
"never" is marked, because it is categorically different from a date rather
than a worse one — and it is marked by weight, not hue.
An empty sweep says "Nothing to check", not nothing: good news must not read
as a broken page.
Two chips (tier, then verification) turned out byte-identical, so .rule-chip
moves to rules-shared.css and snippet #2906 is updated to match rather than
left describing a file that has moved on. Its header comment counted the
panes it served; that count went stale the moment a fourth arrived, so it no
longer counts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
/* Shared by the three rules panes (RulebookListPane, RuleListPane,
|
/* Shared by the rules panes (RulebookListPane, RuleListPane,
|
||||||
RulebookDetailPane): the pane surface and its heading. Load with
|
RulebookDetailPane, RuleSweepPane): the pane surface, its heading, and the
|
||||||
|
title chip. Counting them in this comment went stale the first time a
|
||||||
|
fourth was added, so it no longer does. Load with
|
||||||
<style src="@/assets/rules-shared.css" /> beside the component's own
|
<style src="@/assets/rules-shared.css" /> beside the component's own
|
||||||
scoped block; never restate these there (#2903, milestone 299). */
|
scoped block; never restate these there (#2903, milestone 299). */
|
||||||
.pane {
|
.pane {
|
||||||
@@ -13,3 +15,19 @@
|
|||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
}
|
}
|
||||||
.form-buttons { display: flex; gap: 0.5rem; }
|
.form-buttons { display: flex; gap: 0.5rem; }
|
||||||
|
|
||||||
|
/* A small marker beside a rule's title. Two of these appeared within one
|
||||||
|
milestone (tier, then verification) and were byte-identical; a third would
|
||||||
|
have drifted. The pane's italic serif title is inherited by anything inside
|
||||||
|
it, so the chip resets family and style explicitly. */
|
||||||
|
.rule-chip {
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
font-family: var(--fs-font-body);
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
color: var(--fs-text-secondary);
|
||||||
|
background: var(--fs-surface-raised);
|
||||||
|
border-radius: var(--fs-radius-pill);
|
||||||
|
padding: 0.05rem 0.4rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ const allRulebooks = ref<Rulebook[]>([]);
|
|||||||
const showPicker = ref(false);
|
const showPicker = ref(false);
|
||||||
const expandedRuleIds = ref<Set<number>>(new Set());
|
const expandedRuleIds = ref<Set<number>>(new Set());
|
||||||
|
|
||||||
const ruleDetails = ref<Record<number, { why: string; how_to_apply: string }>>({});
|
const ruleDetails = ref<Record<number, {
|
||||||
|
why: string; how_to_apply: string;
|
||||||
|
verify_with: string; expires_when: string; verified_at: string | null;
|
||||||
|
}>>({});
|
||||||
|
|
||||||
const showProjectRuleForm = ref(false);
|
const showProjectRuleForm = ref(false);
|
||||||
const newProjectRule = ref({
|
const newProjectRule = ref({
|
||||||
@@ -67,6 +70,9 @@ async function toggleRuleExpand(ruleId: number) {
|
|||||||
ruleDetails.value[ruleId] = {
|
ruleDetails.value[ruleId] = {
|
||||||
why: rule.why || "",
|
why: rule.why || "",
|
||||||
how_to_apply: rule.how_to_apply || "",
|
how_to_apply: rule.how_to_apply || "",
|
||||||
|
verify_with: rule.verify_with || "",
|
||||||
|
expires_when: rule.expires_when || "",
|
||||||
|
verified_at: rule.verified_at,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,6 +80,11 @@ async function toggleRuleExpand(ruleId: number) {
|
|||||||
expandedRuleIds.value = new Set(expandedRuleIds.value);
|
expandedRuleIds.value = new Set(expandedRuleIds.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "never run" reads as a stronger claim than an absent date — and it is. */
|
||||||
|
function checkAge(verifiedAt: string | null): string {
|
||||||
|
return verifiedAt ? `last passed ${verifiedAt.slice(0, 10)}` : "never run";
|
||||||
|
}
|
||||||
|
|
||||||
function openInRulesView(rulebookId: number, ruleId?: number) {
|
function openInRulesView(rulebookId: number, ruleId?: number) {
|
||||||
const query: Record<string, string> = { rb: String(rulebookId) };
|
const query: Record<string, string> = { rb: String(rulebookId) };
|
||||||
if (ruleId) query.rule = String(ruleId);
|
if (ruleId) query.rule = String(ruleId);
|
||||||
@@ -279,6 +290,16 @@ watch(() => props.projectId, load);
|
|||||||
<div v-if="ruleDetails[r.id].how_to_apply">
|
<div v-if="ruleDetails[r.id].how_to_apply">
|
||||||
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
|
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Shown only when the rule carries a check. Read-only here: this
|
||||||
|
tab is the project's view of what binds it, and editing a rule
|
||||||
|
belongs on the rulebook surface that owns it. -->
|
||||||
|
<div v-if="ruleDetails[r.id].verify_with">
|
||||||
|
<strong>Check:</strong> {{ ruleDetails[r.id].verify_with }}
|
||||||
|
<span class="rule-check-age">{{ checkAge(ruleDetails[r.id].verified_at) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="ruleDetails[r.id].expires_when">
|
||||||
|
<strong>Ends when:</strong> {{ ruleDetails[r.id].expires_when }}
|
||||||
|
</div>
|
||||||
<button class="delete-link" @click="removeProjectRule(r.id)">Delete</button>
|
<button class="delete-link" @click="removeProjectRule(r.id)">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -332,6 +353,13 @@ watch(() => props.projectId, load);
|
|||||||
<div v-if="ruleDetails[r.id].how_to_apply">
|
<div v-if="ruleDetails[r.id].how_to_apply">
|
||||||
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
|
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="ruleDetails[r.id].verify_with">
|
||||||
|
<strong>Check:</strong> {{ ruleDetails[r.id].verify_with }}
|
||||||
|
<span class="rule-check-age">{{ checkAge(ruleDetails[r.id].verified_at) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="ruleDetails[r.id].expires_when">
|
||||||
|
<strong>Ends when:</strong> {{ ruleDetails[r.id].expires_when }}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
class="edit-link"
|
class="edit-link"
|
||||||
@click="openInRulesView(r.rulebook_id, r.id)"
|
@click="openInRulesView(r.rulebook_id, r.id)"
|
||||||
@@ -428,6 +456,11 @@ ul { list-style: none; padding: 0; margin: 0; }
|
|||||||
}
|
}
|
||||||
.rule-head { cursor: pointer; }
|
.rule-head { cursor: pointer; }
|
||||||
.rule-title { font-weight: 500; }
|
.rule-title { font-weight: 500; }
|
||||||
|
.rule-check-age {
|
||||||
|
margin-left: var(--fs-space-2);
|
||||||
|
color: var(--fs-text-tertiary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
.rule-statement { display: block; opacity: 0.85; margin-top: 0.25rem; }
|
.rule-statement { display: block; opacity: 0.85; margin-top: 0.25rem; }
|
||||||
.rule-detail {
|
.rule-detail {
|
||||||
margin-top: 0.5rem; padding: 0.5rem;
|
margin-top: 0.5rem; padding: 0.5rem;
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const tier = ref<RuleTier>("always_on");
|
|||||||
const systemIds = ref<number[]>([]);
|
const systemIds = ref<number[]>([]);
|
||||||
const why = ref("");
|
const why = ref("");
|
||||||
const howToApply = ref("");
|
const howToApply = ref("");
|
||||||
|
const verifyWith = ref("");
|
||||||
|
const expiresWhen = ref("");
|
||||||
|
|
||||||
const relations = computed(() => store.currentRule?.relations ?? []);
|
const relations = computed(() => store.currentRule?.relations ?? []);
|
||||||
|
|
||||||
@@ -38,6 +40,27 @@ function toggleSystem(id: number) {
|
|||||||
|
|
||||||
const isCreating = ref(props.ruleId === null);
|
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() {
|
async function load() {
|
||||||
if (props.ruleId !== null) {
|
if (props.ruleId !== null) {
|
||||||
await store.fetchRule(props.ruleId);
|
await store.fetchRule(props.ruleId);
|
||||||
@@ -50,6 +73,8 @@ async function load() {
|
|||||||
systemIds.value = (r.systems ?? []).map((sys) => sys.id);
|
systemIds.value = (r.systems ?? []).map((sys) => sys.id);
|
||||||
why.value = r.why || "";
|
why.value = r.why || "";
|
||||||
howToApply.value = r.how_to_apply || "";
|
howToApply.value = r.how_to_apply || "";
|
||||||
|
verifyWith.value = r.verify_with || "";
|
||||||
|
expiresWhen.value = r.expires_when || "";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
title.value = "";
|
title.value = "";
|
||||||
@@ -59,6 +84,8 @@ async function load() {
|
|||||||
systemIds.value = [];
|
systemIds.value = [];
|
||||||
why.value = "";
|
why.value = "";
|
||||||
howToApply.value = "";
|
howToApply.value = "";
|
||||||
|
verifyWith.value = "";
|
||||||
|
expiresWhen.value = "";
|
||||||
}
|
}
|
||||||
await canon.fetchCatalog();
|
await canon.fetchCatalog();
|
||||||
}
|
}
|
||||||
@@ -78,6 +105,11 @@ async function save() {
|
|||||||
system_ids: systemIds.value,
|
system_ids: systemIds.value,
|
||||||
why: why.value,
|
why: why.value,
|
||||||
how_to_apply: howToApply.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) {
|
if (isCreating.value && props.topicId !== null) {
|
||||||
await store.createRule(props.topicId, fields);
|
await store.createRule(props.topicId, fields);
|
||||||
@@ -162,6 +194,45 @@ watch(() => props.ruleId, load);
|
|||||||
</p>
|
</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="check">
|
||||||
|
<legend>Can this rule go stale?</legend>
|
||||||
|
<p class="tier-test 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="tier-test">
|
||||||
|
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>
|
||||||
|
|
||||||
<section v-if="relations.length" class="relations">
|
<section v-if="relations.length" class="relations">
|
||||||
<h3>Related rules</h3>
|
<h3>Related rules</h3>
|
||||||
<ul>
|
<ul>
|
||||||
@@ -231,6 +302,35 @@ legend { padding: 0 0.35rem; font-size: 0.8rem; color: var(--fs-text-tertiary);
|
|||||||
.relation-target { color: var(--fs-text-primary); }
|
.relation-target { color: var(--fs-text-primary); }
|
||||||
.relation-note { width: 100%; font-size: 0.78rem; color: var(--fs-text-tertiary); }
|
.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, .close { background: none; border: none; cursor: pointer; opacity: 0.6; font-size: 1.25em; }
|
||||||
.trash:hover, .close:hover { opacity: 1; }
|
.trash:hover, .close:hover { opacity: 1; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -17,7 +17,17 @@ const emit = defineEmits<{
|
|||||||
{{ r.title }}
|
{{ r.title }}
|
||||||
<!-- Only conditional is marked: always-on is the default and
|
<!-- Only conditional is marked: always-on is the default and
|
||||||
badging every row would say nothing. -->
|
badging every row would say nothing. -->
|
||||||
<span v-if="r.tier === 'conditional'" class="tier-chip" title="Arrives when its trigger fires, rather than in every session">conditional</span>
|
<span v-if="r.tier === 'conditional'" class="rule-chip" title="Arrives when its trigger fires, rather than in every session">conditional</span>
|
||||||
|
<!-- Present only on a rule carrying a check, so the chip's very
|
||||||
|
presence says "this one asserts a fact that can go false". -->
|
||||||
|
<span
|
||||||
|
v-if="r.last_verified"
|
||||||
|
class="rule-chip check-chip"
|
||||||
|
:class="{ unchecked: r.last_verified === 'never' }"
|
||||||
|
:title="r.last_verified === 'never'
|
||||||
|
? 'Asserts a fact nobody has confirmed yet'
|
||||||
|
: `Check last passed ${r.last_verified}`"
|
||||||
|
>{{ r.last_verified === "never" ? "unverified" : `checked ${r.last_verified}` }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="statement">{{ r.statement }}</div>
|
<div class="statement">{{ r.statement }}</div>
|
||||||
<div v-if="r.when_to_apply || r.updated_at" class="meta">
|
<div v-if="r.when_to_apply || r.updated_at" class="meta">
|
||||||
@@ -47,16 +57,13 @@ li:hover { background: var(--fs-surface-hover); }
|
|||||||
.meta { display: flex; align-items: baseline; gap: 0.5rem; margin-top: 0.35rem; font-size: 0.75em; }
|
.meta { display: flex; align-items: baseline; gap: 0.5rem; margin-top: 0.35rem; font-size: 0.75em; }
|
||||||
.trigger { flex: 1; min-width: 0; color: var(--fs-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.trigger { flex: 1; min-width: 0; color: var(--fs-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.age { color: var(--fs-text-tertiary); font-variant-numeric: tabular-nums; flex-shrink: 0; }
|
.age { color: var(--fs-text-tertiary); font-variant-numeric: tabular-nums; flex-shrink: 0; }
|
||||||
.tier-chip {
|
/* Only the departures from .rule-chip (rules-shared.css) live here. */
|
||||||
margin-left: 0.4rem;
|
.check-chip { font-variant-numeric: tabular-nums; }
|
||||||
font-family: var(--fs-font-body);
|
/* No age-graded colour on purpose. The sweep is already ordered by urgency, so
|
||||||
font-style: normal;
|
a red/amber ramp would restate the ordering AND require an invented "stale
|
||||||
font-size: 0.62rem;
|
after N days" threshold — a magic number nobody could defend and the first
|
||||||
color: var(--fs-text-secondary);
|
thing to go out of date. Only "never" is marked, because it is categorically
|
||||||
background: var(--fs-surface-raised);
|
different from a date rather than a worse one. */
|
||||||
border-radius: var(--fs-radius-pill);
|
.check-chip.unchecked { font-style: italic; color: var(--fs-text-tertiary); }
|
||||||
padding: 0.05rem 0.4rem;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
.new-rule { cursor: pointer; }
|
.new-rule { cursor: pointer; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* The staleness sweep: rules that assert a FACT, oldest verification first.
|
||||||
|
*
|
||||||
|
* Cross-cutting by nature — a rule that has gone false does not care which
|
||||||
|
* rulebook it sits in — so this is its own pane rather than a filter on the
|
||||||
|
* per-topic rule list. That list can only ever show one topic of one
|
||||||
|
* rulebook, so filtering it would quietly under-report, which is the exact
|
||||||
|
* failure this surface exists to catch.
|
||||||
|
*/
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||||
|
import type { RuleTier } from "@/api/rulebooks";
|
||||||
|
|
||||||
|
const emit = defineEmits<{ "open-rule": [id: number] }>();
|
||||||
|
|
||||||
|
const store = useRulebooksStore();
|
||||||
|
const neverOnly = ref(false);
|
||||||
|
const tier = ref<RuleTier | "">("");
|
||||||
|
const busyId = ref<number | null>(null);
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
return store.fetchRulesDue({
|
||||||
|
neverOnly: neverOnly.value || undefined,
|
||||||
|
tier: tier.value || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verify(id: number, stillTrue: boolean) {
|
||||||
|
busyId.value = id;
|
||||||
|
try {
|
||||||
|
await store.verifyRule(id, stillTrue);
|
||||||
|
} finally {
|
||||||
|
busyId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(reload);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="pane sweep">
|
||||||
|
<header>
|
||||||
|
<h2>Due for verification</h2>
|
||||||
|
<p class="lede">
|
||||||
|
Rules that assert a fact about something outside your control. Most rules are
|
||||||
|
decisions and never appear here — they have no truth value to go stale.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<label class="filter">
|
||||||
|
<input v-model="neverOnly" type="checkbox" @change="reload" />
|
||||||
|
<span>Never checked only</span>
|
||||||
|
</label>
|
||||||
|
<label class="filter">
|
||||||
|
<span>Tier</span>
|
||||||
|
<select v-model="tier" @change="reload">
|
||||||
|
<option value="">any</option>
|
||||||
|
<option value="always_on">always on</option>
|
||||||
|
<option value="conditional">conditional</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="store.loading" class="state">Loading…</p>
|
||||||
|
|
||||||
|
<!-- An empty sweep is GOOD NEWS, and must not read like a broken page. -->
|
||||||
|
<p v-else-if="!store.rulesDue.length" class="state empty">
|
||||||
|
Nothing to check.
|
||||||
|
{{ neverOnly || tier ? "No rule matches these filters." : "No rule carries a check yet — add one to a rule that asserts a fact." }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ol v-else class="rows">
|
||||||
|
<li v-for="r in store.rulesDue" :key="r.id" class="row">
|
||||||
|
<div class="row-head">
|
||||||
|
<button class="row-title" @click="emit('open-rule', r.id)">{{ r.title }}</button>
|
||||||
|
<span v-if="r.tier === 'always_on'" class="rule-chip" title="Loaded into every session — a wrong one is wrong everywhere at once">always on</span>
|
||||||
|
<span class="age" :class="{ unchecked: r.days_since_verified === null }">
|
||||||
|
{{ r.days_since_verified === null
|
||||||
|
? "never checked"
|
||||||
|
: `${r.days_since_verified}d ago` }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="statement">{{ r.statement }}</p>
|
||||||
|
|
||||||
|
<dl class="check">
|
||||||
|
<dt>Check</dt>
|
||||||
|
<dd><code>{{ r.verify_with }}</code></dd>
|
||||||
|
<template v-if="r.expires_when">
|
||||||
|
<dt>Ends when</dt>
|
||||||
|
<dd>{{ r.expires_when }}</dd>
|
||||||
|
</template>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button :disabled="busyId === r.id" @click="verify(r.id, true)">Still true</button>
|
||||||
|
<button :disabled="busyId === r.id" @click="verify(r.id, false)">No longer true</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<p v-if="store.rulesDue.length" class="footnote">
|
||||||
|
Record a result only after actually running the check. “No longer true” stores nothing
|
||||||
|
on purpose — the rule is wrong rather than in a state worth recording, so it keeps its
|
||||||
|
place here until you correct or retire it.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style src="@/assets/rules-shared.css" />
|
||||||
|
<style scoped>
|
||||||
|
.sweep { display: flex; flex-direction: column; gap: var(--fs-space-3); }
|
||||||
|
.lede {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 62ch;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--fs-text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters { display: flex; gap: var(--fs-space-5); align-items: center; flex-wrap: wrap; }
|
||||||
|
.filter { display: flex; align-items: center; gap: var(--fs-space-2); font-size: 0.82rem; color: var(--fs-text-secondary); }
|
||||||
|
.filter input[type="checkbox"] { accent-color: var(--fs-accent); }
|
||||||
|
.filter select {
|
||||||
|
font: inherit; font-size: 0.82rem;
|
||||||
|
background: var(--fs-surface-page); color: var(--fs-text-primary);
|
||||||
|
border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md);
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state { margin: 0; font-size: 0.9rem; color: var(--fs-text-secondary); }
|
||||||
|
.state.empty { color: var(--fs-text-tertiary); }
|
||||||
|
|
||||||
|
.rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-3); }
|
||||||
|
.row {
|
||||||
|
background: var(--fs-surface-raised);
|
||||||
|
border-radius: var(--fs-radius-md);
|
||||||
|
padding: var(--fs-space-3);
|
||||||
|
}
|
||||||
|
.row-head { display: flex; align-items: baseline; gap: var(--fs-space-2); flex-wrap: wrap; }
|
||||||
|
.row-title {
|
||||||
|
background: none; border: none; padding: 0; cursor: pointer;
|
||||||
|
font-family: Fraunces, serif; font-style: italic; font-size: 1.02rem;
|
||||||
|
color: var(--fs-text-primary); text-align: left;
|
||||||
|
}
|
||||||
|
.row-title:hover { text-decoration: underline; }
|
||||||
|
/* The ORDER carries urgency — the top of this list is the most overdue thing
|
||||||
|
in the rulebook. No red/amber ramp: it would restate the ordering and force
|
||||||
|
an invented "stale after N days" threshold. "Never" is marked because it is
|
||||||
|
categorically different from a date, not a worse one. */
|
||||||
|
.age { margin-left: auto; font-size: 0.78rem; color: var(--fs-text-secondary); font-variant-numeric: tabular-nums; }
|
||||||
|
.age.unchecked { font-style: italic; color: var(--fs-text-tertiary); }
|
||||||
|
|
||||||
|
.statement { margin: 0.35rem 0 0; font-size: 0.88rem; color: var(--fs-text-secondary); }
|
||||||
|
|
||||||
|
.check { display: grid; grid-template-columns: auto 1fr; gap: 0.15rem var(--fs-space-3); margin: var(--fs-space-3) 0 0; }
|
||||||
|
.check dt { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--fs-text-tertiary); }
|
||||||
|
.check dd { margin: 0; font-size: 0.82rem; color: var(--fs-text-primary); min-width: 0; }
|
||||||
|
.check code {
|
||||||
|
font-family: var(--fs-font-mono);
|
||||||
|
background: var(--fs-surface-code-inline);
|
||||||
|
border-radius: var(--fs-radius-sm);
|
||||||
|
padding: 0.05rem 0.3rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions { display: flex; gap: var(--fs-space-2); margin-top: var(--fs-space-3); }
|
||||||
|
.actions button {
|
||||||
|
cursor: pointer; font: inherit; font-size: 0.78rem;
|
||||||
|
background: var(--fs-surface-page); color: var(--fs-text-primary);
|
||||||
|
border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md);
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
}
|
||||||
|
.actions button:hover:not(:disabled) { background: var(--fs-surface-hover); }
|
||||||
|
.actions button:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
|
||||||
|
|
||||||
|
.footnote { margin: 0; max-width: 62ch; font-size: 0.78rem; color: var(--fs-text-tertiary); line-height: 1.45; }
|
||||||
|
</style>
|
||||||
@@ -3,8 +3,8 @@ import { ref } from "vue";
|
|||||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||||
import type { Rulebook } from "@/api/rulebooks";
|
import type { Rulebook } from "@/api/rulebooks";
|
||||||
|
|
||||||
defineProps<{ rulebooks: Rulebook[]; selectedId: number | null }>();
|
defineProps<{ rulebooks: Rulebook[]; selectedId: number | null; sweepActive: boolean }>();
|
||||||
const emit = defineEmits<{ select: [id: number] }>();
|
const emit = defineEmits<{ select: [id: number]; "select-sweep": [] }>();
|
||||||
|
|
||||||
const store = useRulebooksStore();
|
const store = useRulebooksStore();
|
||||||
const isCreating = ref(false);
|
const isCreating = ref(false);
|
||||||
@@ -34,6 +34,18 @@ async function submitNew() {
|
|||||||
<span v-if="rb.always_on" class="always-on-badge" title="Loaded at session start">always on</span>
|
<span v-if="rb.always_on" class="always-on-badge" title="Loaded at session start">always on</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<!-- Not a rulebook, and deliberately below them: a cross-cutting view over
|
||||||
|
every rule the operator owns. It lives here because this is where you
|
||||||
|
come to look at rules, and a rule that has gone false belongs to no
|
||||||
|
one rulebook. -->
|
||||||
|
<button
|
||||||
|
class="sweep-entry"
|
||||||
|
:class="{ active: sweepActive }"
|
||||||
|
@click="emit('select-sweep')"
|
||||||
|
>
|
||||||
|
Due for verification
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="new-rulebook">
|
<div class="new-rulebook">
|
||||||
<button v-if="!isCreating" @click="isCreating = true">+ New rulebook</button>
|
<button v-if="!isCreating" @click="isCreating = true">+ New rulebook</button>
|
||||||
<form v-else @submit.prevent="submitNew">
|
<form v-else @submit.prevent="submitNew">
|
||||||
@@ -63,6 +75,15 @@ li:hover { background: var(--fs-surface-hover); }
|
|||||||
color: var(--fs-text-on-action);
|
color: var(--fs-text-on-action);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
.sweep-entry {
|
||||||
|
display: block; width: 100%; text-align: left;
|
||||||
|
margin-top: var(--fs-space-3);
|
||||||
|
padding: 0.5rem; border-radius: 6px;
|
||||||
|
background: none; border: 1px dashed var(--fs-border-color);
|
||||||
|
color: var(--fs-text-secondary); font: inherit; cursor: pointer;
|
||||||
|
}
|
||||||
|
.sweep-entry:hover { background: var(--fs-surface-hover); }
|
||||||
|
.sweep-entry.active { background: var(--fs-accent-soft); color: var(--fs-text-primary); }
|
||||||
.new-rulebook { margin-top: 1rem; }
|
.new-rulebook { margin-top: 1rem; }
|
||||||
.new-rulebook input {
|
.new-rulebook input {
|
||||||
width: 100%; margin-bottom: 0.5rem;
|
width: 100%; margin-bottom: 0.5rem;
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
|||||||
const topicsByRulebook = ref<Record<number, RulebookTopic[]>>({});
|
const topicsByRulebook = ref<Record<number, RulebookTopic[]>>({});
|
||||||
const rulesByTopic = ref<Record<number, RuleHeader[]>>({});
|
const rulesByTopic = ref<Record<number, RuleHeader[]>>({});
|
||||||
const currentRule = ref<Rule | null>(null);
|
const currentRule = ref<Rule | null>(null);
|
||||||
|
const rulesDue = ref<api.RuleVerificationRow[]>([]);
|
||||||
|
// Kept so a verify re-reads the sweep with the SAME filters the operator is
|
||||||
|
// looking at — re-fetching unfiltered would silently widen the list under
|
||||||
|
// them at the moment they acted on it.
|
||||||
|
const lastSweepOpts = ref<{ olderThanDays?: number; tier?: api.RuleTier; neverOnly?: boolean }>({});
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
async function fetchRulebooks() {
|
async function fetchRulebooks() {
|
||||||
@@ -111,6 +116,13 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
|||||||
updated_at: rule.updated_at,
|
updated_at: rule.updated_at,
|
||||||
when_to_apply: rule.when_to_apply || undefined,
|
when_to_apply: rule.when_to_apply || undefined,
|
||||||
arose_from_id: rule.arose_from_id ?? undefined,
|
arose_from_id: rule.arose_from_id ?? undefined,
|
||||||
|
// Mirrors services.rulebooks.last_verified_label: present ONLY when the
|
||||||
|
// rule carries a check, and "never" rather than absent when it has one
|
||||||
|
// nobody has run. Computed here so a row just written looks identical to
|
||||||
|
// the same row re-fetched, instead of losing its chip until a reload.
|
||||||
|
last_verified: rule.verify_with
|
||||||
|
? (rule.verified_at ? rule.verified_at.slice(0, 10) : "never")
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +160,41 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
|||||||
await fetchRule(refreshRuleId);
|
await fetchRule(refreshRuleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The staleness sweep: rules asserting a fact, oldest verification first. */
|
||||||
|
async function fetchRulesDue(opts: {
|
||||||
|
olderThanDays?: number; tier?: api.RuleTier; neverOnly?: boolean;
|
||||||
|
} = {}) {
|
||||||
|
loading.value = true;
|
||||||
|
lastSweepOpts.value = opts;
|
||||||
|
try {
|
||||||
|
const data = await api.listRulesDueForVerification(opts);
|
||||||
|
rulesDue.value = data.rules;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record that a rule's check was RUN, and what it said.
|
||||||
|
*
|
||||||
|
* A pass re-sorts the row to the back of the sweep, so the list is re-read
|
||||||
|
* rather than patched: the whole point of this surface is an ORDER, and a
|
||||||
|
* locally-mutated row would sit in its old position claiming a new date.
|
||||||
|
* A failure writes nothing server-side and the row keeps its place — also
|
||||||
|
* correct, and also what a re-read shows.
|
||||||
|
*/
|
||||||
|
async function verifyRule(id: number, stillTrue: boolean) {
|
||||||
|
const rule = await api.markRuleVerified(id, stillTrue);
|
||||||
|
if (currentRule.value?.id === id) currentRule.value = rule;
|
||||||
|
for (const tid of Object.keys(rulesByTopic.value)) {
|
||||||
|
const list = rulesByTopic.value[Number(tid)];
|
||||||
|
const idx = list.findIndex((r) => r.id === id);
|
||||||
|
if (idx >= 0) list[idx] = toHeader(rule);
|
||||||
|
}
|
||||||
|
if (rulesDue.value.length) await fetchRulesDue(lastSweepOpts.value);
|
||||||
|
return rule;
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteRule(id: number) {
|
async function deleteRule(id: number) {
|
||||||
await api.deleteRule(id);
|
await api.deleteRule(id);
|
||||||
if (currentRule.value?.id === id) currentRule.value = null;
|
if (currentRule.value?.id === id) currentRule.value = null;
|
||||||
@@ -157,10 +204,11 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rulebooks, topicsByRulebook, rulesByTopic, currentRule, loading,
|
rulebooks, topicsByRulebook, rulesByTopic, currentRule, rulesDue, lastSweepOpts, loading,
|
||||||
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
|
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
|
||||||
createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook,
|
createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook,
|
||||||
createTopic, updateTopic, deleteTopic,
|
createTopic, updateTopic, deleteTopic,
|
||||||
createRule, updateRule, deleteRule, relateRules, unrelateRules,
|
createRule, updateRule, deleteRule, relateRules, unrelateRules,
|
||||||
|
fetchRulesDue, verifyRule,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import RulebookListPane from "@/components/rules/RulebookListPane.vue";
|
|||||||
import RulebookDetailPane from "@/components/rules/RulebookDetailPane.vue";
|
import RulebookDetailPane from "@/components/rules/RulebookDetailPane.vue";
|
||||||
import RuleListPane from "@/components/rules/RuleListPane.vue";
|
import RuleListPane from "@/components/rules/RuleListPane.vue";
|
||||||
import RuleEditorSlideOver from "@/components/rules/RuleEditorSlideOver.vue";
|
import RuleEditorSlideOver from "@/components/rules/RuleEditorSlideOver.vue";
|
||||||
|
import RuleSweepPane from "@/components/rules/RuleSweepPane.vue";
|
||||||
|
|
||||||
const store = useRulebooksStore();
|
const store = useRulebooksStore();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -15,6 +16,7 @@ const selectedRulebookId = ref<number | null>(null);
|
|||||||
const selectedTopicId = ref<number | null>(null);
|
const selectedTopicId = ref<number | null>(null);
|
||||||
const editingRuleId = ref<number | null>(null);
|
const editingRuleId = ref<number | null>(null);
|
||||||
const creatingRuleForTopic = ref<number | null>(null);
|
const creatingRuleForTopic = ref<number | null>(null);
|
||||||
|
const sweepActive = ref(false);
|
||||||
|
|
||||||
function syncFromRoute() {
|
function syncFromRoute() {
|
||||||
const rb = route.query.rb ? Number(route.query.rb) : null;
|
const rb = route.query.rb ? Number(route.query.rb) : null;
|
||||||
@@ -23,9 +25,20 @@ function syncFromRoute() {
|
|||||||
selectedRulebookId.value = rb;
|
selectedRulebookId.value = rb;
|
||||||
selectedTopicId.value = topic;
|
selectedTopicId.value = topic;
|
||||||
editingRuleId.value = rule;
|
editingRuleId.value = rule;
|
||||||
|
sweepActive.value = route.query.view === "due";
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSweep() {
|
||||||
|
sweepActive.value = true;
|
||||||
|
// Keeps ?rule=… so the editor survives the mode switch, and drops the
|
||||||
|
// rulebook/topic selection the sweep does not use.
|
||||||
|
const { rb, topic, ...rest } = route.query;
|
||||||
|
void rb; void topic;
|
||||||
|
router.replace({ query: { ...rest, view: "due" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectRulebook(id: number) {
|
function selectRulebook(id: number) {
|
||||||
|
sweepActive.value = false;
|
||||||
selectedRulebookId.value = id;
|
selectedRulebookId.value = id;
|
||||||
selectedTopicId.value = null;
|
selectedTopicId.value = null;
|
||||||
router.replace({ query: { rb: String(id) } });
|
router.replace({ query: { rb: String(id) } });
|
||||||
@@ -70,10 +83,13 @@ watch(() => route.query, syncFromRoute);
|
|||||||
<RulebookListPane
|
<RulebookListPane
|
||||||
:rulebooks="store.rulebooks"
|
:rulebooks="store.rulebooks"
|
||||||
:selected-id="selectedRulebookId"
|
:selected-id="selectedRulebookId"
|
||||||
|
:sweep-active="sweepActive"
|
||||||
@select="selectRulebook"
|
@select="selectRulebook"
|
||||||
|
@select-sweep="selectSweep"
|
||||||
/>
|
/>
|
||||||
|
<RuleSweepPane v-if="sweepActive" class="sweep-span" @open-rule="openRule" />
|
||||||
<RulebookDetailPane
|
<RulebookDetailPane
|
||||||
v-if="selectedRulebookId !== null"
|
v-else-if="selectedRulebookId !== null"
|
||||||
:rulebook-id="selectedRulebookId"
|
:rulebook-id="selectedRulebookId"
|
||||||
:topics="store.topicsByRulebook[selectedRulebookId] || []"
|
:topics="store.topicsByRulebook[selectedRulebookId] || []"
|
||||||
:selected-topic-id="selectedTopicId"
|
:selected-topic-id="selectedTopicId"
|
||||||
@@ -83,13 +99,13 @@ watch(() => route.query, syncFromRoute);
|
|||||||
<p>Select a rulebook to view its topics.</p>
|
<p>Select a rulebook to view its topics.</p>
|
||||||
</div>
|
</div>
|
||||||
<RuleListPane
|
<RuleListPane
|
||||||
v-if="selectedTopicId !== null"
|
v-if="!sweepActive && selectedTopicId !== null"
|
||||||
:topic-id="selectedTopicId"
|
:topic-id="selectedTopicId"
|
||||||
:rules="store.rulesByTopic[selectedTopicId] || []"
|
:rules="store.rulesByTopic[selectedTopicId] || []"
|
||||||
@open-rule="openRule"
|
@open-rule="openRule"
|
||||||
@create-rule="startCreatingRule"
|
@create-rule="startCreatingRule"
|
||||||
/>
|
/>
|
||||||
<div v-else class="pane empty">
|
<div v-else-if="!sweepActive" class="pane empty">
|
||||||
<p>Select a topic to view its rules.</p>
|
<p>Select a topic to view its rules.</p>
|
||||||
</div>
|
</div>
|
||||||
<RuleEditorSlideOver
|
<RuleEditorSlideOver
|
||||||
@@ -109,6 +125,9 @@ watch(() => route.query, syncFromRoute);
|
|||||||
gap: 1px;
|
gap: 1px;
|
||||||
background: var(--fs-border-color);
|
background: var(--fs-border-color);
|
||||||
}
|
}
|
||||||
|
/* The sweep is cross-cutting, so it takes the width the rulebook + topic
|
||||||
|
panes would have used rather than being squeezed into one column. */
|
||||||
|
.sweep-span { grid-column: 2 / -1; }
|
||||||
.pane.empty {
|
.pane.empty {
|
||||||
background: var(--fs-surface-hover);
|
background: var(--fs-surface-hover);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user