Files
FabledScribe/frontend/src/components/InlineAssistPanel.vue
T
bvandeusenandClaude Opus 5 d0a2733cb6
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / integration (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m12s
CI & Build / Build & push image (push) Successful in 1m2s
fix(design): text on a tint of itself now clears AA app-wide, and the check gates it (#3141)
The badge fix (#3132) exposed the same defect everywhere: 48 rules painting a
token as TEXT on an inline color-mix tint of that same token. Worst raw
measurements, across every tint strength in use, both modes, over
page/raised/hover:

  accent 1.53:1 · success 1.67:1 · text-tertiary 2.15:1
  warning 2.32:1 · error 2.36:1                        against AA's 4.5

THE DEFECT IS IN THE HOUSE, NOT IN SCRIBE. The semantic hues are shared
family-wide, and the accent case was measured against every app's real
accent, not assumed from Scribe's: Minstrel 1.81, Forge 1.87, Steward 1.65,
Roundtable 3.01 — all failing. So the six -fg tokens are recorded on
FabledSword (design system 1), where their parents live, rather than copied
into each app.

45% toward --fs-text-primary clears AA for ALL FIVE accents (4.56-5.00), so
this is one house token rather than five overrides, and it keeps deriving
from --fs-accent — an app that overrides its accent still gets a legible
tinted-text colour in its own colour, the same mechanism as
--fs-accent-soft. The tokens are additive: a sibling app is unaffected until
it regenerates its own stylesheet.

One token is honestly redundant. --fs-text-secondary already passes at
4.82:1, and --fs-text-secondary-fg barely moves it. It exists so the rule
has NO exceptions, because the alternative is a permanent allow-list entry
for the one case that happens to pass — and a guard with an invisible
exception is a guard that erodes.

46 substitutions across 18 files, each rewriting only the `color:` inside a
block that tints its own background.

THE CHECK NOW GATES BOTH SPELLINGS. It previously reported the inline form,
because a gate nobody can satisfy on the day it lands gets switched off.
Both are clean, so both fail the build now.

And the check had a false-positive bug worth naming: its `color\s*:` regex
matched the tail of `border-color`, `border-left-color` and `outline-color`,
so it flagged seven rules that were already correct. A border is a non-text
graphic with a 3:1 floor, not text at 4.5. A check that cries wolf on
correct code is one that gets muted, so that mattered more than the noise.

Verified by construction, not by passing: reintroduced each defect form
(exit 1 each), and confirmed a legitimate border-only rule still exits 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:38:09 -04:00

259 lines
6.2 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 { ref, computed } from "vue";
import { renderMarkdown } from "@/utils/markdown";
import type { DiffLine } from "@/composables/useAssist";
const props = defineProps<{
phase: "streaming" | "review";
label: string;
streamingText: string;
diff: DiffLine[];
proposedText: string;
}>();
const emit = defineEmits<{
accept: [];
reject: [];
cancel: [];
}>();
const showFullText = ref(false);
const renderedStreaming = computed(() => renderMarkdown(props.streamingText));
const renderedProposed = computed(() => renderMarkdown(props.proposedText));
const markers: Record<DiffLine["type"], string> = {
equal: " ",
delete: "",
insert: "+",
};
</script>
<template>
<!-- ── Streaming ───────────────────────────────────────────────── -->
<div v-if="phase === 'streaming'" class="iap iap-streaming">
<div class="iap-header">
<span class="iap-pulse"></span>
<span class="iap-label">{{ label }}</span>
<button class="iap-btn-cancel" @click="emit('cancel')">Cancel</button>
</div>
<div class="iap-stream-preview prose" v-html="renderedStreaming"></div>
<div v-if="!streamingText" class="iap-waiting">Waiting for model</div>
</div>
<!-- ── Review ─────────────────────────────────────────────────── -->
<div v-else-if="phase === 'review'" class="iap iap-review">
<div class="iap-header">
<span class="iap-review-title">Proposed changes</span>
<button class="iap-btn-toggle" @click="showFullText = !showFullText">
{{ showFullText ? "Show diff" : "Show full text" }}
</button>
<div class="iap-actions">
<button class="iap-btn-accept" @click="emit('accept')"> Accept</button>
<button class="iap-btn-reject" @click="emit('reject')"> Reject</button>
</div>
</div>
<!-- Diff view -->
<div v-if="!showFullText" class="iap-diff">
<div
v-for="(line, i) in diff"
:key="i"
:class="['iap-diff-line', `iap-diff-${line.type}`]"
>
<span class="iap-diff-marker">{{ markers[line.type] }}</span>
<span class="iap-diff-text">{{ line.text }}</span>
</div>
<div v-if="!diff.length" class="iap-diff-empty">No changes.</div>
</div>
<!-- Full text view -->
<div v-else class="iap-full-text prose" v-html="renderedProposed"></div>
</div>
</template>
<style scoped>
.iap {
border-radius: var(--fs-radius-sm);
margin-bottom: 0.75rem;
overflow: hidden;
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-page);
}
/* ── Header ── */
.iap-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.75rem;
font-size: 0.85rem;
border-bottom: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
}
/* ── Streaming ── */
.iap-streaming {
border-color: var(--fs-accent);
}
.iap-streaming .iap-header {
background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-raised));
}
.iap-pulse {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--fs-accent);
flex-shrink: 0;
animation: iap-pulse 1.2s ease-in-out infinite;
}
@keyframes iap-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.8); }
}
.iap-label {
flex: 1;
font-weight: 500;
color: var(--fs-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.iap-btn-cancel {
background: none;
border: 1px solid var(--fs-border-color);
color: var(--fs-text-secondary);
border-radius: var(--fs-radius-sm);
padding: 0.15rem 0.5rem;
cursor: pointer;
font-size: 0.8rem;
font-family: inherit;
flex-shrink: 0;
}
.iap-btn-cancel:hover {
border-color: var(--fs-error);
color: var(--fs-error);
}
.iap-stream-preview {
padding: 0.75rem;
font-size: 0.9rem;
min-height: 2.5rem;
max-height: 320px;
overflow-y: auto;
}
.iap-waiting {
padding: 0.75rem;
font-size: 0.85rem;
color: var(--fs-text-tertiary);
}
/* ── Review ── */
.iap-review-title {
flex: 1;
font-weight: 600;
color: var(--fs-text-primary);
}
.iap-btn-toggle {
background: none;
border: 1px solid var(--fs-border-color);
color: var(--fs-text-secondary);
border-radius: var(--fs-radius-sm);
padding: 0.15rem 0.5rem;
cursor: pointer;
font-size: 0.78rem;
font-family: inherit;
}
.iap-btn-toggle:hover {
border-color: var(--fs-accent);
color: var(--fs-accent);
}
.iap-actions {
display: flex;
gap: 0.35rem;
}
.iap-btn-accept,
.iap-btn-reject {
border: none;
border-radius: var(--fs-radius-sm);
padding: 0.2rem 0.65rem;
cursor: pointer;
font-size: 0.8rem;
font-family: inherit;
font-weight: var(--fs-weight-medium);
}
.iap-btn-accept {
background: var(--fs-success);
color: var(--fs-text-on-action);
}
.iap-btn-accept:hover { opacity: 0.85; }
.iap-btn-reject {
background: var(--fs-surface-raised);
color: var(--fs-text-secondary);
border: 1px solid var(--fs-border-color);
}
.iap-btn-reject:hover {
border-color: var(--fs-error);
color: var(--fs-error);
}
/* ── Diff ── */
.iap-diff {
font-family: ui-monospace, "Cascadia Code", "Fira Mono", monospace;
font-size: 0.82rem;
line-height: 1.6;
max-height: 440px;
overflow-y: auto;
padding: 0.4rem 0;
}
.iap-diff-line {
display: flex;
gap: 0.5rem;
padding: 0 0.75rem;
white-space: pre-wrap;
word-break: break-word;
}
.iap-diff-equal { color: var(--fs-text-tertiary); }
.iap-diff-delete {
background: color-mix(in srgb, var(--fs-error) 10%, transparent);
color: var(--fs-error-fg);
}
.iap-diff-insert {
background: color-mix(in srgb, var(--fs-success) 10%, transparent);
color: var(--fs-success-fg);
}
.iap-diff-marker {
width: 1ch;
flex-shrink: 0;
font-weight: 700;
user-select: none;
}
.iap-diff-empty {
padding: 0.75rem;
color: var(--fs-text-tertiary);
font-size: 0.85rem;
font-family: inherit;
}
/* ── Full text ── */
.iap-full-text {
padding: 0.75rem;
font-size: 0.9rem;
max-height: 440px;
overflow-y: auto;
}
</style>