CI & Build / Python lint (push) Successful in 9s
CI & Build / Plugin hooks (push) Successful in 22s
CI & Build / TypeScript typecheck (push) Successful in 1m0s
CI & Build / integration (push) Successful in 1m55s
CI & Build / Python tests (push) Successful in 2m20s
CI & Build / Build & push image (push) Successful in 1m7s
Components, stores, api clients, router, types and shared CSS now state what each piece is and why, without the milestone that introduced it. diff.ts names its consumers instead of the copy history; stores/notes and tasks state their current scope instead of the cleanup that set it; theme.css states the token practice instead of the sweep that established it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
227 lines
8.7 KiB
Vue
227 lines
8.7 KiB
Vue
<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>
|