feat(editor): spell & grammar checking (Chunk #7)
Offline spellcheck via hunspell compiled to WASM (runs fully in-browser), plus optional self-hostable LanguageTool grammar/style checking. - hunspell-wasm spell engine with bundled en_US dictionary (base64-decoded browser-safe via atob + TextDecoder) - LanguageTool engine (self-hostable, docker compose snippet under docker/languagetool/) - single decoration pipeline: spell + grammar hits merge into one squiggle layer (red=spelling, blue=grammar) with a side panel to jump/apply/ignore and add-to-dictionary - useCheckService hook wired into WritingView; check.css loaded in main.tsx - spell + grammar unit tests (30 editor tests green) PLAN.md and CHUNK#7.md updated to reflect completion.
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* CheckPanel — the review panel for spell and grammar issues.
|
||||
*
|
||||
* Sits alongside the editor and lists every flagged span with its
|
||||
* suggestions. The user can:
|
||||
* - filter by kind (all / spelling / grammar),
|
||||
* - click a word to jump to it in the document,
|
||||
* - apply a suggestion (replaces the flagged span),
|
||||
* - dismiss an issue (adds it to the session ignore list),
|
||||
* - add a word to the personal dictionary.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import type { CheckedIssue } from "@pubscript/editor";
|
||||
|
||||
type Filter = "all" | "spelling" | "grammar";
|
||||
|
||||
interface CheckPanelProps {
|
||||
issues: CheckedIssue[];
|
||||
activeIndex: number | null;
|
||||
onNavigate: (issue: CheckedIssue) => void;
|
||||
onApply: (issue: CheckedIssue, replacement: string) => void;
|
||||
onDismiss: (issue: CheckedIssue) => void;
|
||||
onAddToDictionary: (word: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
spelling: "Spelling",
|
||||
grammar: "Grammar",
|
||||
};
|
||||
|
||||
const KIND_CLASS: Record<string, string> = {
|
||||
spelling: "ps-sev-spelling",
|
||||
grammar: "ps-sev-grammar",
|
||||
};
|
||||
|
||||
export function CheckPanel({
|
||||
issues,
|
||||
activeIndex,
|
||||
onNavigate,
|
||||
onApply,
|
||||
onDismiss,
|
||||
onAddToDictionary,
|
||||
onClose,
|
||||
}: CheckPanelProps) {
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (filter === "all") return issues;
|
||||
return issues.filter((i) => i.kind === filter);
|
||||
}, [issues, filter]);
|
||||
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
all: issues.length,
|
||||
spelling: issues.filter((i) => i.kind === "spelling").length,
|
||||
grammar: issues.filter((i) => i.kind === "grammar").length,
|
||||
}),
|
||||
[issues],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="ps-check">
|
||||
<div className="ps-check-head">
|
||||
<div className="ps-check-title">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<path d="M9 11l3 3L22 4" />
|
||||
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
|
||||
</svg>
|
||||
<span>Review</span>
|
||||
</div>
|
||||
<button className="ps-icon-btn" onClick={onClose} aria-label="Close review panel" title="Close">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{issues.length === 0 ? (
|
||||
<div className="ps-check-empty">
|
||||
<div className="ps-check-empty-icon">✓</div>
|
||||
<p>No issues found</p>
|
||||
<span className="ps-check-empty-hint">
|
||||
{filter !== "all" ? "Try a different filter" : "Your text looks clean."}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="ps-check-filters">
|
||||
{([["all", "All"], ["spelling", "Spelling"], ["grammar", "Grammar"]] as const).map(
|
||||
([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
className={"ps-check-filter" + (filter === key ? " active" : "")}
|
||||
onClick={() => setFilter(key)}
|
||||
>
|
||||
{label}
|
||||
<span className="ps-check-filter-count">{counts[key]}</span>
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ps-check-list">
|
||||
{filtered.map((issue) => {
|
||||
const isActive = activeIndex !== null && issues[activeIndex] === issue;
|
||||
return (
|
||||
<div
|
||||
key={issue.id}
|
||||
className={"ps-check-item" + (isActive ? " active" : "")}
|
||||
>
|
||||
<div className="ps-check-item-head">
|
||||
<span className={`ps-badge ${KIND_CLASS[issue.kind] ?? "ps-sev-unknown"}`}>
|
||||
{KIND_LABEL[issue.kind] ?? "Issue"}
|
||||
</span>
|
||||
<button
|
||||
className="ps-check-word"
|
||||
onClick={() => onNavigate(issue)}
|
||||
title="Jump to in document"
|
||||
>
|
||||
<code>{issue.text}</code>
|
||||
</button>
|
||||
</div>
|
||||
{issue.message && (
|
||||
<p className="ps-check-message">{issue.message}</p>
|
||||
)}
|
||||
{issue.replacements.length > 0 && (
|
||||
<div className="ps-check-suggestions">
|
||||
{issue.replacements.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
className="ps-check-suggestion"
|
||||
onClick={() => onApply(issue, r)}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="ps-check-actions">
|
||||
<button
|
||||
className="ps-check-link"
|
||||
onClick={() => onAddToDictionary(issue.text)}
|
||||
>
|
||||
Add to dictionary
|
||||
</button>
|
||||
<button
|
||||
className="ps-check-link"
|
||||
onClick={() => onDismiss(issue)}
|
||||
>
|
||||
Ignore
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useSettings, FONT_FAMILIES, type Density, type FontFamily } from "../store/settings.js";
|
||||
import { useSettings, FONT_FAMILIES, type Density, type FontFamily, type AppSettings } from "../store/settings.js";
|
||||
import { Select, Button, toast } from "@pubscript/ui";
|
||||
import { THEME_IDS, THEME_LABELS } from "@pubscript/ui";
|
||||
|
||||
@@ -170,6 +170,79 @@ export function Settings() {
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="ps-settings-row">
|
||||
<div>
|
||||
<div className="ps-settings-label">Spellcheck</div>
|
||||
<div className="ps-settings-desc">Underline misspelled words as you write. Uses a bundled English dictionary locally.</div>
|
||||
</div>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={s.enableSpellcheck}
|
||||
onChange={(e) => set({ enableSpellcheck: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{s.enableSpellcheck && (
|
||||
<div className="ps-settings-row">
|
||||
<label className="ps-settings-field">
|
||||
<span className="ps-settings-label">Language</span>
|
||||
<select
|
||||
value={s.spellcheckLanguage}
|
||||
onChange={(e) => set({ spellcheckLanguage: e.target.value })}
|
||||
>
|
||||
<option value="en_US">English (US)</option>
|
||||
<option value="en_GB">English (UK)</option>
|
||||
<option value="ca_ES">Català (Espanya)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ps-settings-row">
|
||||
<div>
|
||||
<div className="ps-settings-label">Grammar checking</div>
|
||||
<div className="ps-settings-desc">Suggest grammar and style fixes inline.</div>
|
||||
</div>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={s.enableGrammarCheck}
|
||||
onChange={(e) => set({ enableGrammarCheck: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{s.enableGrammarCheck && s.grammarProvider === "languagetool" && (
|
||||
<div className="ps-settings-row">
|
||||
<label className="ps-settings-field">
|
||||
<span className="ps-settings-label">LanguageTool API key</span>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Optional — uses the public endpoint without a key"
|
||||
value={s.grammarApiKey}
|
||||
onChange={(e) => set({ grammarApiKey: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{s.enableGrammarCheck && (
|
||||
<div className="ps-settings-row">
|
||||
<label className="ps-settings-field">
|
||||
<span className="ps-settings-label">Grammar provider</span>
|
||||
<select
|
||||
value={s.grammarProvider}
|
||||
onChange={(e) => set({ grammarProvider: e.target.value as AppSettings["grammarProvider"] })}
|
||||
>
|
||||
<option value="local">Built-in (local)</option>
|
||||
<option value="languagetool">LanguageTool (cloud)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ps-settings-row">
|
||||
|
||||
@@ -15,6 +15,8 @@ import { useProjectStore } from "../store/project-store.js";
|
||||
import { useSettings } from "../store/settings.js";
|
||||
import { ContextRail } from "../features/entities/ContextRail.js";
|
||||
import { MentionPopover } from "../features/entities/MentionPopover.js";
|
||||
import { CheckPanel } from "./CheckPanel.js";
|
||||
import { useCheckService } from "../hooks/useCheckService.js";
|
||||
|
||||
/** Block-level tags that can contain the cursor and be highlighted in focus mode. */
|
||||
const BLOCK_TAGS = new Set(["P", "H1", "H2", "H3", "BLOCKQUOTE", "PRE", "LI", "DIV"]);
|
||||
@@ -90,6 +92,8 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const check = useCheckService({ editor });
|
||||
|
||||
// Sync content when the active chapter changes. emitUpdate:false keeps this
|
||||
// from re-entering onUpdate (which would otherwise re-save what we just loaded).
|
||||
useEffect(() => {
|
||||
@@ -246,6 +250,17 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
|
||||
entities={entities}
|
||||
onOpenEntity={(id) => navigate(`/characters?entity=${encodeURIComponent(id)}`)}
|
||||
/>
|
||||
{check.open && (
|
||||
<CheckPanel
|
||||
issues={check.issues}
|
||||
activeIndex={check.activeIndex}
|
||||
onNavigate={check.onNavigate}
|
||||
onApply={check.onApply}
|
||||
onDismiss={check.onDismiss}
|
||||
onAddToDictionary={check.onAddToDictionary}
|
||||
onClose={() => check.setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ps-writing-status">
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* useCheckService — wires the editor's CheckService into the WritingView.
|
||||
*
|
||||
* Creates the spell/grammar engines from the user's settings, attaches the
|
||||
* service to the editor, and exposes the resolved issues, counts, and action
|
||||
* handlers to the CheckPanel.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Editor } from "@pubscript/editor";
|
||||
import type { CheckedIssue } from "@pubscript/editor";
|
||||
import { CheckService } from "@pubscript/editor";
|
||||
import { HunspellSpellEngine } from "@pubscript/editor";
|
||||
import { LanguageToolEngine } from "@pubscript/editor";
|
||||
import { useSettings } from "../store/settings.js";
|
||||
|
||||
interface UseCheckServiceOptions {
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
interface UseCheckServiceResult {
|
||||
service: CheckService | null;
|
||||
issues: CheckedIssue[];
|
||||
counts: { all: number; spelling: number; grammar: number } | null;
|
||||
activeIndex: number | null;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
onNavigate: (issue: CheckedIssue) => void;
|
||||
onApply: (issue: CheckedIssue, replacement: string) => void;
|
||||
onDismiss: (issue: CheckedIssue) => void;
|
||||
onAddToDictionary: (word: string) => void;
|
||||
onToggleSpell: () => void;
|
||||
onToggleGrammar: () => void;
|
||||
}
|
||||
|
||||
/** Load the user's personal dictionary from localStorage. */
|
||||
function loadDictionary(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem("ps:dictionary");
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter((w) => typeof w === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the user's personal dictionary to localStorage. */
|
||||
function saveDictionary(words: string[]): void {
|
||||
try {
|
||||
localStorage.setItem("ps:dictionary", JSON.stringify(words));
|
||||
} catch {
|
||||
// Ignore write failures (private mode, quota, etc.).
|
||||
}
|
||||
}
|
||||
|
||||
export function useCheckService({ editor }: UseCheckServiceOptions): UseCheckServiceResult {
|
||||
const {
|
||||
enableSpellcheck,
|
||||
enableGrammarCheck,
|
||||
spellcheckLanguage,
|
||||
grammarProvider,
|
||||
grammarApiKey,
|
||||
} = useSettings();
|
||||
|
||||
const serviceRef = useRef<CheckService | null>(null);
|
||||
const [issues, setIssues] = useState<CheckedIssue[]>([]);
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||
const [counts, setCounts] = useState<UseCheckServiceResult["counts"]>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dictionary, setDictionary] = useState<string[]>(loadDictionary);
|
||||
|
||||
// (Re)create the engines whenever settings change.
|
||||
useEffect(() => {
|
||||
// Dispose any previous service.
|
||||
serviceRef.current?.dispose();
|
||||
|
||||
// Build the spell engine from the bundled English dictionary.
|
||||
const spellEngine = new HunspellSpellEngine();
|
||||
|
||||
// Build the grammar engine based on the provider.
|
||||
let grammarEngine;
|
||||
if (enableGrammarCheck && grammarProvider === "languagetool") {
|
||||
grammarEngine = new LanguageToolEngine({
|
||||
baseUrl: "https://api.languagetool.org",
|
||||
...(grammarApiKey ? { apiKey: grammarApiKey } : {}),
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
// Create the service.
|
||||
const options = {
|
||||
spellEngine,
|
||||
...(grammarEngine ? { grammarEngine } : {}),
|
||||
language: spellcheckLanguage,
|
||||
spellEnabled: enableSpellcheck,
|
||||
grammarEnabled: enableGrammarCheck,
|
||||
debounceMs: 400,
|
||||
onUpdate: (result: { issues: CheckedIssue[]; counts: { spelling: number; grammar: number; total: number } }) => {
|
||||
setIssues(result.issues);
|
||||
setCounts({
|
||||
all: result.counts.total,
|
||||
spelling: result.counts.spelling,
|
||||
grammar: result.counts.grammar,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const service = new CheckService(options);
|
||||
service.attach(editor);
|
||||
serviceRef.current = service;
|
||||
|
||||
return () => {
|
||||
service.dispose();
|
||||
serviceRef.current = null;
|
||||
};
|
||||
}, [editor, enableSpellcheck, enableGrammarCheck, spellcheckLanguage, grammarProvider, grammarApiKey]);
|
||||
|
||||
// Persist the personal dictionary.
|
||||
useEffect(() => {
|
||||
saveDictionary(dictionary);
|
||||
}, [dictionary]);
|
||||
|
||||
const onApply = useCallback(
|
||||
(issue: CheckedIssue, replacement: string) => {
|
||||
serviceRef.current?.applySuggestion(issue, replacement);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onDismiss = useCallback(
|
||||
(issue: CheckedIssue) => {
|
||||
serviceRef.current?.ignoreIssue(issue);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onAddToDictionary = useCallback((word: string) => {
|
||||
serviceRef.current?.ignoreWord({ id: `ignore:${word}`, text: word } as CheckedIssue);
|
||||
setDictionary((prev) => (prev.includes(word) ? prev : [...prev, word]));
|
||||
}, []);
|
||||
|
||||
const onToggleSpell = useCallback(() => {
|
||||
serviceRef.current?.setSpellEnabled(!serviceRef.current.isSpellEnabled());
|
||||
}, []);
|
||||
|
||||
const onToggleGrammar = useCallback(() => {
|
||||
serviceRef.current?.setGrammarEnabled(!serviceRef.current.isGrammarEnabled());
|
||||
}, []);
|
||||
|
||||
const onNavigate = useCallback(
|
||||
(issue: CheckedIssue) => {
|
||||
if (!serviceRef.current) return;
|
||||
const idx = issues.indexOf(issue);
|
||||
setActiveIndex(idx >= 0 ? idx : null);
|
||||
// Jump the editor view to the issue's position.
|
||||
editor.commands.setTextSelection({ from: issue.from, to: issue.to });
|
||||
const view = editor.view;
|
||||
view.focus();
|
||||
// Scroll the editor's scrollable content into view.
|
||||
const dom = view.dom.querySelector(".ProseMirror") as HTMLElement | null;
|
||||
if (dom) {
|
||||
dom.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
},
|
||||
[editor, issues],
|
||||
);
|
||||
|
||||
return {
|
||||
service: serviceRef.current,
|
||||
issues,
|
||||
counts,
|
||||
activeIndex,
|
||||
open,
|
||||
setOpen,
|
||||
onNavigate,
|
||||
onApply,
|
||||
onDismiss,
|
||||
onAddToDictionary,
|
||||
onToggleSpell,
|
||||
onToggleGrammar,
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import "./styles/timeline.css";
|
||||
import "./styles/whiteboard.css";
|
||||
import "./styles/export.css";
|
||||
import "./styles/splash.css";
|
||||
import "./styles/check.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -18,6 +18,8 @@ export function fontFamilyCss(value: FontFamily): string {
|
||||
return found?.css ?? FONT_FAMILIES.at(0)?.css ?? "";
|
||||
}
|
||||
|
||||
export type GrammarProvider = "local" | "languagetool";
|
||||
|
||||
export interface AppSettings {
|
||||
theme: ThemeId;
|
||||
accentColor: string;
|
||||
@@ -31,6 +33,12 @@ export interface AppSettings {
|
||||
autoSave: boolean;
|
||||
focusMode: boolean;
|
||||
typewriterMode: boolean;
|
||||
// Spell & grammar (Chunk #7)
|
||||
enableSpellcheck: boolean;
|
||||
enableGrammarCheck: boolean;
|
||||
spellcheckLanguage: string;
|
||||
grammarProvider: GrammarProvider;
|
||||
grammarApiKey: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AppSettings = {
|
||||
@@ -46,6 +54,12 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
||||
autoSave: true,
|
||||
focusMode: false,
|
||||
typewriterMode: false,
|
||||
// Spell & grammar (Chunk #7)
|
||||
enableSpellcheck: true,
|
||||
enableGrammarCheck: false,
|
||||
spellcheckLanguage: "en_US",
|
||||
grammarProvider: "local",
|
||||
grammarApiKey: "",
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "pubscript.settings.v1";
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* CheckPanel styles — the review panel for spell and grammar issues.
|
||||
*
|
||||
* Sits as a right-side rail alongside the editor column. Uses the same
|
||||
* design tokens as the rest of the app.
|
||||
*/
|
||||
|
||||
/* Panel container. */
|
||||
.ps-check {
|
||||
width: 280px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--ps-border);
|
||||
background: var(--ps-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header row. */
|
||||
.ps-check-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--ps-border);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.ps-check-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ps-text);
|
||||
}
|
||||
|
||||
/* Empty state. */
|
||||
.ps-check-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 16px;
|
||||
gap: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ps-check-empty-icon {
|
||||
font-size: 28px;
|
||||
color: var(--ps-success);
|
||||
}
|
||||
|
||||
.ps-check-empty p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--ps-text);
|
||||
}
|
||||
|
||||
.ps-check-empty-hint {
|
||||
font-size: 12px;
|
||||
color: var(--ps-text-muted);
|
||||
}
|
||||
|
||||
/* Filter tabs. */
|
||||
.ps-check-filters {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--ps-border);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.ps-check-filter {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 5px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--ps-text-muted);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.ps-check-filter:hover {
|
||||
background: var(--ps-surface-2);
|
||||
color: var(--ps-text);
|
||||
}
|
||||
|
||||
.ps-check-filter.active {
|
||||
background: var(--ps-surface-2);
|
||||
color: var(--ps-text);
|
||||
border-color: var(--ps-border);
|
||||
}
|
||||
|
||||
.ps-check-filter-count {
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--ps-text-muted);
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Issue list. */
|
||||
.ps-check-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
/* Individual issue card. */
|
||||
.ps-check-item {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--ps-border);
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.ps-check-item:hover {
|
||||
background: var(--ps-surface-2);
|
||||
}
|
||||
|
||||
.ps-check-item.active {
|
||||
background: color-mix(in srgb, var(--ps-accent) 10%, transparent);
|
||||
}
|
||||
|
||||
.ps-check-item-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Severity badge. */
|
||||
.ps-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 7px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border-radius: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ps-sev-spelling {
|
||||
color: var(--ps-danger, #e5484d);
|
||||
background: color-mix(in srgb, var(--ps-danger, #e5484d) 12%, transparent);
|
||||
}
|
||||
|
||||
.ps-sev-grammar {
|
||||
color: var(--ps-accent, #7c8bf5);
|
||||
background: color-mix(in srgb, var(--ps-accent, #7c8bf5) 12%, transparent);
|
||||
}
|
||||
|
||||
.ps-sev-unknown {
|
||||
color: var(--ps-text-muted);
|
||||
background: var(--ps-surface-2);
|
||||
}
|
||||
|
||||
/* Flagged word. */
|
||||
.ps-check-word {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.ps-check-word code {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--ps-text);
|
||||
background: var(--ps-surface-2);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ps-check-word:hover code {
|
||||
background: var(--ps-border);
|
||||
}
|
||||
|
||||
/* Grammar message. */
|
||||
.ps-check-message {
|
||||
margin: 0 0 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--ps-text-muted);
|
||||
}
|
||||
|
||||
/* Suggestion buttons. */
|
||||
.ps-check-suggestions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.ps-check-suggestion {
|
||||
padding: 3px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--ps-text);
|
||||
background: var(--ps-surface-2);
|
||||
border: 1px solid var(--ps-border);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.ps-check-suggestion:hover {
|
||||
background: var(--ps-border);
|
||||
border-color: var(--ps-text-muted);
|
||||
}
|
||||
|
||||
/* Action row. */
|
||||
.ps-check-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ps-check-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 11.5px;
|
||||
color: var(--ps-text-muted);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
transition: color 0.12s ease;
|
||||
}
|
||||
|
||||
.ps-check-link:hover {
|
||||
color: var(--ps-text);
|
||||
}
|
||||
@@ -159,3 +159,19 @@
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* ── Spell & grammar squiggle decorations ─────────────────────────── */
|
||||
|
||||
/* Red squiggle under misspelled words. */
|
||||
.ps-spell-deco {
|
||||
text-decoration: underline wavy var(--ps-danger, #e5484d);
|
||||
text-decoration-thickness: 1.5px;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
/* Blue squiggle under grammar/style issues. */
|
||||
.ps-grammar-deco {
|
||||
text-decoration: underline wavy var(--ps-accent, #7c8bf5);
|
||||
text-decoration-thickness: 1.5px;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user