Files
FabledScribe/frontend/src/components/MarkdownToolbar.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

175 lines
5.4 KiB
Vue

<script setup lang="ts">
import type { Editor } from "@tiptap/vue-3";
import {
Bold,
Italic,
Strikethrough,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
ListChecks,
Code,
SquareCode,
Quote,
Link as LinkIcon,
Brackets,
} from "lucide-vue-next";
import type { Component } from "vue";
const props = defineProps<{ editor: Editor | null }>();
const ICONS: Record<string, Component> = {
bold: Bold,
italic: Italic,
strike: Strikethrough,
h1: Heading1,
h2: Heading2,
h3: Heading3,
ul: List,
ol: ListOrdered,
task: ListChecks,
code: Code,
codeblock: SquareCode,
quote: Quote,
link: LinkIcon,
wikilink: Brackets,
};
const groups = [
[
{ id: "bold", title: "Bold (Ctrl+B)", isActive: () => props.editor?.isActive("bold") ?? false, command: () => props.editor?.chain().focus().toggleBold().run() },
{ id: "italic", title: "Italic (Ctrl+I)", isActive: () => props.editor?.isActive("italic") ?? false, command: () => props.editor?.chain().focus().toggleItalic().run() },
{ id: "strike", title: "Strikethrough", isActive: () => props.editor?.isActive("strike") ?? false, command: () => props.editor?.chain().focus().toggleStrike().run() },
],
[
{ id: "h1", title: "Heading 1", isActive: () => props.editor?.isActive("heading", { level: 1 }) ?? false, command: () => props.editor?.chain().focus().toggleHeading({ level: 1 }).run() },
{ id: "h2", title: "Heading 2", isActive: () => props.editor?.isActive("heading", { level: 2 }) ?? false, command: () => props.editor?.chain().focus().toggleHeading({ level: 2 }).run() },
{ id: "h3", title: "Heading 3", isActive: () => props.editor?.isActive("heading", { level: 3 }) ?? false, command: () => props.editor?.chain().focus().toggleHeading({ level: 3 }).run() },
],
[
{ id: "ul", title: "Bullet List", isActive: () => props.editor?.isActive("bulletList") ?? false, command: () => props.editor?.chain().focus().toggleBulletList().run() },
{ id: "ol", title: "Ordered List", isActive: () => props.editor?.isActive("orderedList") ?? false, command: () => props.editor?.chain().focus().toggleOrderedList().run() },
{ id: "task", title: "Task List", isActive: () => props.editor?.isActive("taskList") ?? false, command: () => props.editor?.chain().focus().toggleTaskList().run() },
],
[
{ id: "code", title: "Inline Code", isActive: () => props.editor?.isActive("code") ?? false, command: () => props.editor?.chain().focus().toggleCode().run() },
{ id: "codeblock", title: "Code Block", isActive: () => props.editor?.isActive("codeBlock") ?? false, command: () => props.editor?.chain().focus().toggleCodeBlock().run() },
{ id: "quote", title: "Blockquote", isActive: () => props.editor?.isActive("blockquote") ?? false, command: () => props.editor?.chain().focus().toggleBlockquote().run() },
],
[
{
id: "link",
title: "Link",
isActive: () => props.editor?.isActive("link") ?? false,
command: () => {
const ed = props.editor;
if (!ed) return;
if (ed.isActive("link")) {
ed.chain().focus().unsetLink().run();
} else {
const url = prompt("URL:");
if (url) ed.chain().focus().setLink({ href: url }).run();
}
},
},
{
id: "wikilink",
title: "Insert wikilink [[ ]]",
isActive: () => false,
command: () => props.editor?.chain().focus().insertContent("[[").run(),
},
],
];
</script>
<template>
<div class="md-toolbar" role="toolbar" aria-label="Text formatting">
<template v-for="(group, gi) in groups" :key="gi">
<div class="toolbar-group">
<button
v-for="btn in group"
:key="btn.id"
:class="['md-btn', { active: btn.isActive() }]"
:title="btn.title"
type="button"
tabindex="-1"
@mousedown.prevent="btn.command()"
>
<component :is="ICONS[btn.id]" class="btn-icon" :size="16" />
</button>
</div>
<span v-if="gi < groups.length - 1" class="toolbar-sep" aria-hidden="true" />
</template>
</div>
</template>
<style scoped>
.md-toolbar {
display: flex;
align-items: center;
gap: 2px;
flex-wrap: wrap;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 3px 4px;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 1px;
}
.toolbar-sep {
display: block;
width: 1px;
height: 18px;
background: var(--fs-border-color);
flex-shrink: 0;
margin: 0 3px;
}
.md-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 5px;
background: transparent;
color: var(--fs-text-secondary);
cursor: pointer;
padding: 0;
transition: background 0.12s, color 0.12s, box-shadow 0.12s;
flex-shrink: 0;
}
.md-btn:hover {
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.md-btn.active {
background: color-mix(in srgb, var(--fs-accent) 14%, transparent);
color: var(--fs-accent-fg);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--fs-accent) 35%, transparent);
}
.md-btn.active:hover {
background: color-mix(in srgb, var(--fs-accent) 22%, transparent);
}
.btn-icon {
display: flex;
align-items: center;
justify-content: center;
line-height: 0;
pointer-events: none;
}
</style>