Files
FabledGrimoire/frontend/src/components/ReplyShapesSettings.vue
T
bvandeusenandClaude Opus 5.5 bdd803537b
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
docs(6208): the remaining frontend comments drop milestone, step and rule citations
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>
2026-10-21 21:14:03 -04:00

227 lines
8.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { apiErrorMessage } from "@/api/client";
import { getReplyShapes, type ReplyShape, type ReplyShapesOverview } from "@/api/replyShapes";
import type { RulePreset } from "@/api/rulebooks";
import RuleEditorSlideOver from "@/components/rules/RuleEditorSlideOver.vue";
/**
* The reply shapes Scribe ships, and the operator's adjustments to each
* A shape is product, so its text is read-only here;
* what the operator owns is the preferences mounted on the shape's moment,
* which arrive beside it and win where they differ. "Adjust this" opens the
* preference editor already mounted there.
*/
const data = ref<ReplyShapesOverview | null>(null);
const loading = ref(true);
const error = ref("");
const open = ref<string | null>(null);
async function load() {
loading.value = true;
error.value = "";
try {
data.value = await getReplyShapes();
} catch (e: unknown) {
error.value = apiErrorMessage(e, "Could not load the reply shapes");
} finally {
loading.value = false;
}
}
const shapes = computed(() => data.value?.shapes ?? []);
const adjusted = computed(() => shapes.value.filter((s) => s.preferences.length).length);
const delivered = computed(() =>
shapes.value.reduce((n, s) => n + (s.deliveries ? s.deliveries.full + s.deliveries.pointer : 0), 0),
);
// One line answering "what do my replies get shaped by?".
const verdict = computed(() => {
if (!data.value) return "";
const n = shapes.value.length;
const parts = [`${n} ${n === 1 ? "shape ships" : "shapes ship"} with Scribe`];
parts.push(adjusted.value
? `${adjusted.value} adjusted by your preferences`
: "none adjusted — the defaults stand");
if (!data.value.deliveries_failed) parts.push(`delivered ${delivered.value} times in ${data.value.days} days`);
return parts.join(" · ");
});
function deliveryLabel(s: ReplyShape): string {
if (!s.deliveries) return "—";
const { full, pointer } = s.deliveries;
if (!full && !pointer) return "not delivered";
return `${full} in full · ${pointer} as reminder`;
}
function toggle(key: string) {
open.value = open.value === key ? null : key;
}
// ── The editor: an existing preference, or a new one mounted here ─────────
const editingId = ref<number | null>(null);
const preset = ref<RulePreset | null>(null);
const editorOpen = computed(() => editingId.value !== null || preset.value !== null);
function editPreference(id: number) {
preset.value = null;
editingId.value = id;
}
function adjust(s: ReplyShape) {
editingId.value = null;
preset.value = {
kind: "preference",
moments: [s.moment],
// A starting trigger in the moment's own words; the operator sharpens it.
whenToApply: `When ${s.means}.`,
};
}
function closeEditor() {
editingId.value = null;
preset.value = null;
void load();
}
onMounted(load);
</script>
<template>
<div class="reply-shapes">
<p v-if="loading && !data" class="state">Loading…</p>
<p v-else-if="error" class="state state-error" role="alert">
{{ error }}
<button type="button" class="btn-text" @click="load">Retry</button>
</p>
<template v-else-if="data">
<p class="verdict">{{ verdict }}</p>
<p v-if="data.deliveries_failed" class="state state-error" role="alert">
Delivery counts could not be read, so they show as “—”.
<button type="button" class="btn-text" @click="load">Retry</button>
</p>
<ul class="shape-list">
<li v-for="s in shapes" :key="s.key" :class="['shape-row', { open: open === s.key }]">
<button
type="button"
class="shape-head"
:aria-expanded="open === s.key"
:aria-controls="`shape-${s.key}`"
@click="toggle(s.key)"
>
<span class="shape-name">
<strong>{{ s.title }}</strong>
<span class="shape-when">{{ s.delivered }}</span>
</span>
<span class="shape-stats">
<span :class="{ 'stat-none': !s.preferences.length }">
{{ s.preferences.length
? `${s.preferences.length} ${s.preferences.length === 1 ? "preference" : "preferences"}`
: "default" }}
</span>
<span :class="{ 'stat-none': deliveryLabel(s) === 'not delivered' }">{{ deliveryLabel(s) }}</span>
<span class="chevron" aria-hidden="true">{{ open === s.key ? "▾" : "▸" }}</span>
</span>
</button>
<div v-if="open === s.key" :id="`shape-${s.key}`" class="shape-body">
<h4 class="body-label">Ships with Scribe · at <code>{{ s.moment }}</code></h4>
<div class="shape-text">{{ s.text }}</div>
<h4 class="body-label">Your adjustments</h4>
<ul v-if="s.preferences.length" class="pref-list">
<li v-for="p in s.preferences" :key="p.id">
<button type="button" class="pref-row" @click="editPreference(p.id)">
<span class="pref-title">{{ p.title }}</span>
<span class="chevron" aria-hidden="true">›</span>
</button>
</li>
</ul>
<p v-else class="stat-none">None — this shape arrives as shipped.</p>
<button type="button" class="btn-primary adjust" @click="adjust(s)">Adjust this</button>
</div>
</li>
</ul>
</template>
<RuleEditorSlideOver
v-if="editorOpen"
:rule-id="editingId"
:topic-id="null"
:preset="preset"
@close="closeEditor"
/>
</div>
</template>
<style scoped>
.reply-shapes { display: flex; flex-direction: column; gap: var(--fs-space-2); }
.verdict { margin: 0; font-size: var(--fs-size-body-sm); color: var(--fs-text-primary); }
.state { margin: 0; font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); }
.state-error { color: var(--fs-error); }
.shape-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-2); }
.shape-row {
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-md);
}
/* The whole row is the tap target (a settings row, not prose with a link). */
.shape-head {
width: 100%;
display: flex; align-items: baseline; gap: var(--fs-space-3);
padding: var(--fs-space-3);
background: none; border: 0; border-radius: var(--fs-radius-md);
color: inherit; font: inherit; text-align: left; cursor: pointer;
}
.shape-head:focus-visible { outline: 2px solid var(--fs-focus-ring); outline-offset: 2px; }
.shape-name { display: flex; flex-direction: column; gap: var(--fs-space-1); min-width: 0; }
.shape-name strong { font-size: var(--fs-size-body-sm); color: var(--fs-text-primary); }
.shape-when {
font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.shape-stats {
margin-left: auto;
display: flex; align-items: baseline; gap: var(--fs-space-3);
font-size: var(--fs-size-tiny); color: var(--fs-text-secondary);
font-variant-numeric: tabular-nums; white-space: nowrap;
}
.stat-none { color: var(--fs-text-tertiary); font-style: italic; }
.chevron { color: var(--fs-text-tertiary); }
.shape-body {
display: flex; flex-direction: column; align-items: flex-start; gap: var(--fs-space-2);
padding: 0 var(--fs-space-3) var(--fs-space-3);
}
.body-label {
margin: var(--fs-space-2) 0 0;
font-size: var(--fs-size-tiny); font-weight: normal;
text-transform: uppercase; letter-spacing: var(--fs-tracking-tiny);
color: var(--fs-text-tertiary);
}
.body-label code { font-family: var(--fs-font-mono); text-transform: none; }
/* Product text, shown as it reaches a session. Read-only by design. */
.shape-text {
width: 100%;
padding: var(--fs-space-2) var(--fs-space-3);
border-left: 2px solid var(--fs-border-color);
font-size: var(--fs-size-body-sm); line-height: var(--fs-leading-body);
color: var(--fs-text-secondary); white-space: pre-wrap;
}
.pref-list { list-style: none; margin: 0; padding: 0; width: 100%; display: flex; flex-direction: column; gap: var(--fs-space-1); }
.pref-row {
width: 100%;
display: flex; align-items: baseline; gap: var(--fs-space-2);
padding: var(--fs-space-1) var(--fs-space-2);
background: none; border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-sm);
color: var(--fs-text-primary); font: inherit; font-size: var(--fs-size-body-sm);
text-align: left; cursor: pointer;
}
.pref-row:focus-visible { outline: 2px solid var(--fs-focus-ring); outline-offset: 2px; }
.pref-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pref-row .chevron { margin-left: auto; }
.adjust { margin-top: var(--fs-space-1); }
</style>