From 85a8efa2e95d34d5ae062140a8c6c314bdbe3ced Mon Sep 17 00:00:00 2001 From: Elijah Potter Date: Fri, 12 Sep 2025 09:15:46 -0600 Subject: [PATCH] feat(chrome-ext): show hints randomly (#1909) Co-authored-by: ethqnol --- harper-core/src/linting/dashes.rs | 6 +-- ...Constitution of the United States.snap.yml | 2 +- packages/lint-framework/src/assets/hints.json | 13 ++++++ .../lint-framework/src/lint/PopupHandler.ts | 32 ++++++++++++- .../lint-framework/src/lint/SuggestionBox.ts | 45 +++++++++++++++++++ packages/lint-framework/tsconfig.json | 1 + 6 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 packages/lint-framework/src/assets/hints.json diff --git a/harper-core/src/linting/dashes.rs b/harper-core/src/linting/dashes.rs index 960b0270..79799adc 100644 --- a/harper-core/src/linting/dashes.rs +++ b/harper-core/src/linting/dashes.rs @@ -42,14 +42,14 @@ impl ExprLinter for Dashes { span, lint_kind, suggestions: vec![Suggestion::ReplaceWith(vec![EN_DASH])], - message: "A sequence of hyphens is not an en dash.".to_owned(), + message: "Replace these two hyphens with an en dash (–).".to_owned(), priority: 63, }), 3 => Some(Lint { span, lint_kind, suggestions: vec![Suggestion::ReplaceWith(vec![EM_DASH])], - message: "A sequence of hyphens is not an em dash.".to_owned(), + message: "Replace these three hyphens with an em dash (—).".to_owned(), priority: 63, }), 4.. => None, // Ignore longer hyphen sequences. @@ -58,7 +58,7 @@ impl ExprLinter for Dashes { } fn description(&self) -> &'static str { - "Rather than outright using an em dash or en dash, authors often use a sequence of hyphens, expecting them to be condensed. Use two hyphens to denote an en dash and three to denote an em dash." + "Writers often type `--` or `---` expecting their editor to convert them into proper dashes. Replace these sequences with the correct characters: use an en dash (–) for ranges or connections and an em dash (—) for a break in thought." } } diff --git a/harper-core/tests/text/linters/The Constitution of the United States.snap.yml b/harper-core/tests/text/linters/The Constitution of the United States.snap.yml index 8ee57734..7759b1f4 100644 --- a/harper-core/tests/text/linters/The Constitution of the United States.snap.yml +++ b/harper-core/tests/text/linters/The Constitution of the United States.snap.yml @@ -1156,7 +1156,7 @@ Message: | Lint: Formatting (63 priority) Message: | 455 | Oath or Affirmation:-- "I do solemnly swear (or affirm) that I will faithfully - | ^~ A sequence of hyphens is not an en dash. + | ^~ Replace these two hyphens with an en dash (–). Suggest: - Replace with: “–” diff --git a/packages/lint-framework/src/assets/hints.json b/packages/lint-framework/src/assets/hints.json new file mode 100644 index 00000000..2c33ad2e --- /dev/null +++ b/packages/lint-framework/src/assets/hints.json @@ -0,0 +1,13 @@ +[ + "Vary sentence length to keep readers engaged.", + "Replace clichés with fresh, precise wording.", + "Check subject‑verb agreement, especially in long sentences.", + "Keep consistent terminology across the document.", + "Read aloud to catch awkward phrasing.", + "You can easily write em-dashes by typing out three hyphens in a row.", + "You can easily write en-dashes by typing out two hyphens in a row.", + "Harper can be configured to open with a hotkey. Check the extension's settings.", + "Don't agree with a suggestion? Click 'Ignore' and Harper will adapt to your writing style.", + "You can add specialized terms, names, or acronyms to your personal dictionary in Harper's settings.", + "Check your language preferences in Settings to get suggestions for different dialects (e.g., American vs. British English)." +] diff --git a/packages/lint-framework/src/lint/PopupHandler.ts b/packages/lint-framework/src/lint/PopupHandler.ts index 5d89b17a..1378cf39 100644 --- a/packages/lint-framework/src/lint/PopupHandler.ts +++ b/packages/lint-framework/src/lint/PopupHandler.ts @@ -4,6 +4,7 @@ import { getCaretPosition } from './editorUtils'; type ActivationKey = 'off' | 'shift' | 'control'; +import hintsData from '../assets/hints.json'; import RenderBox from './RenderBox'; import SuggestionBox from './SuggestionBox'; @@ -29,6 +30,8 @@ function monitorActivationKey( export default class PopupHandler { private currentLintBoxes: IgnorableLintBox[]; private popupLint: number | undefined; + private currentHint: string | null | undefined; + private currentHintFor: number | undefined; private renderBox: RenderBox; private pointerDownCallback: (e: PointerEvent) => void; private activationKeyListener: (() => void) | undefined; @@ -45,6 +48,8 @@ export default class PopupHandler { }) { this.actions = actions; this.currentLintBoxes = []; + this.currentHint = undefined; + this.currentHintFor = undefined; this.renderBox = new RenderBox(document.body); this.renderBox.getShadowHost().popover = 'manual'; this.renderBox.getShadowHost().style.pointerEvents = 'none'; @@ -104,10 +109,14 @@ export default class PopupHandler { private render() { let tree = h('div', {}, []); + this.updateHint(); + if (this.popupLint != null && this.popupLint < this.currentLintBoxes.length) { const box = this.currentLintBoxes[this.popupLint]; - tree = SuggestionBox(box, this.actions, () => { + + tree = SuggestionBox(box, this.actions, this.currentHint ?? null, () => { this.popupLint = undefined; + this.updateHint(); }); this.renderBox.getShadowHost().showPopover(); } else { @@ -117,6 +126,27 @@ export default class PopupHandler { this.renderBox.render(tree); } + /** Synchronize the hint with the currently focused lint. + * - If no lint is open, clear the hint state. + * - If a different lint opens, or the hint is uninitialized, decide once (~10%). + */ + private updateHint() { + if (this.popupLint == null) { + this.currentHint = undefined; + this.currentHintFor = undefined; + return; + } + + if (this.currentHintFor !== this.popupLint || this.currentHint === undefined) { + const hints: string[] = Array.isArray(hintsData) + ? ((hintsData as unknown[]).filter((v) => typeof v === 'string') as string[]) + : []; + const show = Math.random() < 0.1 && hints.length > 0; + this.currentHint = show ? hints[Math.floor(Math.random() * hints.length)] : null; + this.currentHintFor = this.popupLint; + } + } + public updateLintBoxes(boxes: IgnorableLintBox[]) { this.currentLintBoxes.forEach((b) => { b.source.removeEventListener('pointerdown', this.pointerDownCallback as EventListener); diff --git a/packages/lint-framework/src/lint/SuggestionBox.ts b/packages/lint-framework/src/lint/SuggestionBox.ts index 3239fd30..d07ac3ec 100644 --- a/packages/lint-framework/src/lint/SuggestionBox.ts +++ b/packages/lint-framework/src/lint/SuggestionBox.ts @@ -123,6 +123,19 @@ function footer(leftChildren: any, rightChildren: any) { return h('div', { className: 'harper-footer' }, [left, right]); } +function hintDrawer(hint: string | null): any { + if (!hint) return undefined; + return h('div', { className: 'harper-hint-drawer', role: 'note', 'aria-live': 'polite' }, [ + h('div', { className: 'harper-hint-content' }, [ + h('div', { className: 'harper-hint-icon', 'aria-hidden': 'true' }, '💡'), + h('div', {}, [ + h('div', { className: 'harper-hint-title' }, 'Tip'), + h('div', {}, String(hint)), + ]), + ]), + ]); +} + function addToDictionary( box: LintBox, addToUserDictionary?: (words: string[]) => Promise, @@ -229,6 +242,33 @@ function styleTag() { gap:16px } + /* Hint drawer styles */ + .harper-hint-drawer{ + margin-top:6px; + border-top:1px solid #eaeef2; + background:#f6f8fa; + color:#3e4c59; + border-radius:0 0 6px 6px; + } + .harper-hint-content{ + display:flex; + gap:8px; + align-items:flex-start; + padding:8px 10px; + font-size:13px; + line-height:18px; + } + .harper-hint-icon{ + flex:0 0 auto; + width:18px;height:18px; + border-radius:50%; + background:#fff3c4; + color:#7c5e10; + display:flex;align-items:center;justify-content:center; + font-weight:700; + } + .harper-hint-title{ font-weight:600; margin-right:6px; color:#1f2328; } + .fade-in { animation: fadeIn 100ms ease-in-out forwards; } @@ -261,6 +301,9 @@ function styleTag() { background:#4b4b4b; color:#ffffff } + .harper-hint-drawer{ border-top-color:#30363d; background:#151b23; color:#9aa4af; } + .harper-hint-icon{ background:#3a2f0b; color:#f2cc60; } + .harper-hint-title{ color:#e6edf3; } }`, ]); } @@ -280,6 +323,7 @@ export default function SuggestionBox( openOptions?: () => Promise; addToUserDictionary?: (words: string[]) => Promise; }, + hint: string | null, close: () => void, ) { const top = box.y + box.height + 3; @@ -325,6 +369,7 @@ export default function SuggestionBox( box.ignoreLint ? ignoreLint(box.ignoreLint) : undefined, ], ), + hintDrawer(hint), ], ); } diff --git a/packages/lint-framework/tsconfig.json b/packages/lint-framework/tsconfig.json index b22f521c..12671b09 100644 --- a/packages/lint-framework/tsconfig.json +++ b/packages/lint-framework/tsconfig.json @@ -5,6 +5,7 @@ "module": "ESNext", "lib": ["ES2020", "DOM", "DOM.Iterable"], "skipLibCheck": true, + "resolveJsonModule": true, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true,