Horizon Studios brings each release together in a single place — starting with Horizon AI, an AI-powered chat and study workspace.
Horizon AI pairs an open-ended chat interface with dedicated study tools, so a conversation can turn into a quiz or a set of flashcards without switching apps.
Each project starts as its own focused idea, designed around one clear use case.
Projects are built independently and shipped as standalone, self-contained apps.
Finished projects join the studio here, all reachable from a single home page.
Horizon Grammar runs as a userscript through Tampermonkey — a free browser extension. Follow the steps below to get it running on any page.
Click the Tampermonkey icon in the toolbar, open the dashboard, then choose "Create a new script." A code editor with placeholder text will appear.
Select all the placeholder code in the editor and delete it, then copy the Horizon Grammar script below and paste it in.
Save the script (File → Save, or Ctrl/Cmd + S), then reload any open tab. A small circular icon will appear in the corner of the page — that's Horizon Grammar, ready to use.
// ==UserScript==
// @name Horizon Grammar
// @namespace horizon-grammar
// @version 1.8.0
// @description AI writing assistant: grammar/spelling/punctuation fixes + tone rewrites (formal, informal, humanize, longer, shorter, simplify) on any page.
// @author you
// @match *://*/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @connect api.openai.com
// @connect openrouter.ai
// @connect generativelanguage.googleapis.com
// @connect *
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
/* ---------------------------------------------------------------------
* STATE / STORAGE
* ------------------------------------------------------------------- */
const store = {
get apiKey() { return GM_getValue('hg_api_key', ''); },
set apiKey(v) { GM_setValue('hg_api_key', v); },
get baseUrl() { return GM_getValue('hg_base_url', 'https://api.openai.com/v1/chat/completions'); },
set baseUrl(v){ GM_setValue('hg_base_url', v); },
get model() { return GM_getValue('hg_model', 'gpt-4o-mini'); },
set model(v) { GM_setValue('hg_model', v); },
get pos() { return GM_getValue('hg_pos', { right: 24, bottom: 24 }); },
set pos(v) { GM_setValue('hg_pos', v); },
};
let lastEditable = null; // last focused editable element on the page
let capturedSelection = null; // {mode:'textarea'|'ce', el, start, end, range}
/* ---------------------------------------------------------------------
* STYLES
* ------------------------------------------------------------------- */
GM_addStyle(`
#hg-fab {
position: fixed;
width: 52px; height: 52px;
border-radius: 50%;
background: #0a0a0a;
box-shadow: 0 0 0 1.5px rgba(255,255,255,.55), 0 0 10px 2px rgba(255,255,255,.25), 0 4px 14px rgba(0,0,0,.35);
cursor: grab;
z-index: 2147483000;
display: flex; align-items: center; justify-content: center;
user-select: none;
transition: transform .12s ease, box-shadow .15s ease;
}
#hg-fab:hover {
box-shadow: 0 0 0 1.5px rgba(255,255,255,.8), 0 0 14px 3px rgba(255,255,255,.4), 0 4px 14px rgba(0,0,0,.35);
}
#hg-fab:active { cursor: grabbing; transform: scale(0.96); }
#hg-fab svg { width: 30px; height: 30px; pointer-events: none; }
#hg-panel {
position: fixed;
width: 300px;
max-height: 480px;
background: #101012;
color: #f2f2f2;
border-radius: 14px;
box-shadow: 0 10px 40px rgba(0,0,0,.5);
z-index: 2147483001;
font: 13px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: none;
flex-direction: column;
overflow: hidden;
border: 1px solid #262626;
}
#hg-panel.hg-open { display: flex; }
#hg-header {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 12px;
background: #000;
border-bottom: 1px solid #262626;
}
#hg-header .hg-title { font-weight: 600; font-size: 13px; display:flex; align-items:center; gap:6px;}
#hg-header button {
background: none; border: none; color: #aaa; cursor: pointer; font-size: 14px; padding: 2px 6px;
}
#hg-header button:hover { color: #fff; }
#hg-body { padding: 10px 12px; overflow-y: auto; }
.hg-settings { display: none; flex-direction: column; gap: 6px; margin-bottom: 10px; }
.hg-settings.hg-show { display: flex; }
.hg-settings input {
background: #1b1b1e; border: 1px solid #333; color: #fff;
border-radius: 6px; padding: 6px 8px; font-size: 12px; width: 100%; box-sizing: border-box;
}
.hg-settings label { font-size: 11px; color: #999; margin-bottom: -2px; }
.hg-save-btn {
background: #fff; color: #000; border: none; border-radius: 6px;
padding: 6px 8px; font-size: 12px; font-weight: 600; cursor: pointer; margin-top: 2px;
}
.hg-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 10px; }
.hg-grid button {
background: #1b1b1e; color: #fff; border: 1px solid #2c2c2c;
border-radius: 8px; padding: 8px 4px; font-size: 11.5px; cursor: pointer;
transition: background .1s ease;
}
.hg-grid button:hover { background: #262629; }
.hg-grid button.hg-primary { grid-column: span 2; background: #fff; color: #000; font-weight: 600; }
.hg-grid button.hg-primary:hover { background: #e6e6e6; }
#hg-status { font-size: 11.5px; color: #999; min-height: 14px; margin-bottom: 6px; }
#hg-status.hg-error { color: #ff6b6b; }
#hg-status.hg-ok { color: #6bdc8f; }
#hg-output {
width: 100%; box-sizing: border-box;
background: #1b1b1e; color: #fff; border: 1px solid #333; border-radius: 8px;
padding: 8px; font-size: 12.5px; min-height: 70px; max-height: 160px;
resize: vertical; margin-bottom: 8px; display: none;
}
.hg-output-actions { display: none; gap: 6px; margin-bottom: 4px; }
.hg-output-actions.hg-show { display: flex; }
.hg-output-actions button {
flex: 1; background: #1b1b1e; border: 1px solid #333; color: #fff;
border-radius: 6px; padding: 6px; font-size: 12px; cursor: pointer;
}
.hg-output-actions button.hg-apply { background: #fff; color: #000; font-weight: 600; border: none; }
#hg-gear { cursor: pointer; }
`);
/* ---------------------------------------------------------------------
* ICON (black circle, white curved horizon line — placeholder mark)
* ------------------------------------------------------------------- */
const LOGO_SVG = `
<svg viewBox="0 0 52 52" xmlns="http://www.w3.org/2000/svg">
<circle cx="26" cy="26" r="25" fill="#0a0a0a" stroke="#ffffff" stroke-width="1.5" stroke-opacity="0.55"/>
<path d="M6 30 Q26 14 46 30" stroke="#ffffff" stroke-width="2.5"
fill="none" stroke-linecap="round"/>
</svg>`;
/* ---------------------------------------------------------------------
* BUILD UI
* ------------------------------------------------------------------- */
const fab = document.createElement('div');
fab.id = 'hg-fab';
fab.innerHTML = LOGO_SVG;
document.documentElement.appendChild(fab);
const panel = document.createElement('div');
panel.id = 'hg-panel';
panel.innerHTML = `
<div id="hg-header">
<div class="hg-title">Horizon Grammar</div>
<div>
<button id="hg-gear" title="Settings">⚙</button>
<button id="hg-close" title="Close">✕</button>
</div>
</div>
<div id="hg-body">
<div class="hg-settings" id="hg-settings">
<label>API Key</label>
<input id="hg-key" type="password" placeholder="sk-..." autocomplete="new-password" spellcheck="false" />
<div id="hg-key-hint" style="font-size:10.5px;color:#777;margin-top:-4px;"></div>
<label>API Base URL (OpenAI-compatible /chat/completions)</label>
<input id="hg-url" type="text" autocomplete="off" spellcheck="false" />
<label>Model</label>
<input id="hg-model" type="text" autocomplete="off" spellcheck="false" />
<button class="hg-save-btn" id="hg-save">Save settings</button>
</div>
<div id="hg-status"></div>
<div class="hg-grid">
<button class="hg-primary" data-action="grammar">Check Grammar & Spelling</button>
<button data-action="punctuation">Fix Punctuation</button>
<button data-action="clarity">Improve Clarity</button>
<button data-action="formal">Make Formal</button>
<button data-action="informal">Make Informal</button>
<button data-action="longer">Make Longer</button>
<button data-action="shorter">Make Shorter</button>
<button data-action="simplify">Simplify</button>
<button data-action="humanize">Humanize</button>
</div>
<textarea id="hg-output" readonly></textarea>
<div class="hg-output-actions" id="hg-output-actions">
<button id="hg-copy">Copy</button>
<button class="hg-apply" id="hg-apply">Apply</button>
</div>
</div>
`;
document.documentElement.appendChild(panel);
const els = {
status: panel.querySelector('#hg-status'),
output: panel.querySelector('#hg-output'),
outputActions: panel.querySelector('#hg-output-actions'),
settings: panel.querySelector('#hg-settings'),
key: panel.querySelector('#hg-key'),
url: panel.querySelector('#hg-url'),
model: panel.querySelector('#hg-model'),
};
els.key.value = store.apiKey;
els.url.value = store.baseUrl;
els.model.value = store.model;
const keyHint = panel.querySelector('#hg-key-hint');
function refreshKeyHint() {
const k = store.apiKey;
keyHint.textContent = k ? `Currently saved: ${k.length} chars, ending "...${k.slice(-4)}"` : 'No key saved yet.';
}
refreshKeyHint();
function setStatus(msg, type) {
els.status.textContent = msg || '';
els.status.className = type ? `hg-${type}` : '';
}
/* ---------------------------------------------------------------------
* POSITIONING / DRAG
* ------------------------------------------------------------------- */
function applyPos() {
const p = store.pos;
fab.style.right = p.right + 'px';
fab.style.bottom = p.bottom + 'px';
}
applyPos();
// Pointer Events + setPointerCapture: reliable even if the pointer crosses
// iframes/other elements mid-drag, and gives a clean, non-flaky click-vs-drag test.
let dragging = false, moved = false, startX, startY, startRight, startBottom;
const DRAG_THRESHOLD = 6; // px of movement before it counts as a drag, not a click
fab.addEventListener('pointerdown', (e) => {
if (e.button !== undefined && e.button !== 0) return; // left click / primary touch only
dragging = true; moved = false;
startX = e.clientX; startY = e.clientY;
const p = store.pos;
startRight = p.right; startBottom = p.bottom;
fab.setPointerCapture(e.pointerId);
e.preventDefault(); // keep focus/selection on whatever the user was editing
});
fab.addEventListener('pointermove', (e) => {
if (!dragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) moved = true;
if (!moved) return; // don't move the button at all until past the threshold
const newRight = Math.min(Math.max(startRight - dx, 4), window.innerWidth - 56);
const newBottom = Math.min(Math.max(startBottom - dy, 4), window.innerHeight - 56);
fab.style.right = newRight + 'px';
fab.style.bottom = newBottom + 'px';
});
function endDrag(e) {
if (!dragging) return;
dragging = false;
try { fab.releasePointerCapture(e.pointerId); } catch (err) {}
if (moved) {
store.pos = {
right: parseFloat(fab.style.right),
bottom: parseFloat(fab.style.bottom),
};
} else {
togglePanel();
}
}
fab.addEventListener('pointerup', endDrag);
fab.addEventListener('pointercancel', endDrag);
// Stop the panel's own buttons from blurring/collapsing the selection in the
// page's text field. Real inputs inside the panel (settings, output box)
// still get normal focus so you can type/select in them.
panel.addEventListener('mousedown', (e) => {
const t = e.target;
if (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA') return;
e.preventDefault();
}, true);
function togglePanel() {
const open = panel.classList.toggle('hg-open');
if (open) positionPanel();
}
function positionPanel() {
const r = fab.getBoundingClientRect();
let left = r.left - 300 + r.width;
let bottom = window.innerHeight - r.top + 10;
left = Math.min(Math.max(left, 8), window.innerWidth - 308);
panel.style.left = left + 'px';
panel.style.bottom = bottom + 'px';
panel.style.top = 'auto';
}
panel.querySelector('#hg-close').addEventListener('click', () => panel.classList.remove('hg-open'));
panel.querySelector('#hg-gear').addEventListener('click', () => els.settings.classList.toggle('hg-show'));
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') panel.classList.remove('hg-open'); });
panel.querySelector('#hg-save').addEventListener('click', () => {
let key = els.key.value.trim();
key = key.replace(/^Bearer\s+/i, ''); // strip an accidentally-pasted "Bearer " prefix
store.apiKey = key;
els.key.value = key;
store.baseUrl = els.url.value.trim() || 'https://api.openai.com/v1/chat/completions';
store.model = els.model.value.trim() || 'gpt-4o-mini';
refreshKeyHint();
if (key.length > 120) {
setStatus('That API key looks unusually long (' + key.length + ' chars) — typical keys are 40-80 characters. It may have old text mixed in; clear the field completely and paste just the key.', 'error');
} else {
setStatus('Settings saved.', 'ok');
}
});
/* ---------------------------------------------------------------------
* TRACK LAST FOCUSED EDITABLE ELEMENT
* ------------------------------------------------------------------- */
function isEditable(el) {
if (!el) return false;
if (fab.contains(el) || panel.contains(el)) return false; // never track our own UI
if (el.tagName === 'TEXTAREA') return true;
if (el.tagName === 'INPUT' && ['text', 'search', 'email', 'url', ''].includes(el.type)) return true;
if (el.isContentEditable) return true;
return false;
}
document.addEventListener('focusin', (e) => {
if (isEditable(e.target)) lastEditable = e.target;
}, true);
function isKnownCanvasEditor() {
const host = location.hostname;
return /(^|\.)docs\.google\.com$/.test(host) ||
/(^|\.)officeapps\.live\.com$/.test(host) ||
/(^|\.)sharepoint\.com$/.test(host) ||
/(^|\.)onedrive\.live\.com$/.test(host) ||
/(^|\.)office\.com$/.test(host);
}
/* ---------------------------------------------------------------------
* CAPTURE TEXT / SELECTION
* ------------------------------------------------------------------- */
function captureInput() {
// Canvas-rendered editors (Google Docs, Word Online, etc.) have no real DOM
// text to read — use the clipboard flow for them.
if (isKnownCanvasEditor()) return { mode: 'clipboard' };
const el = (lastEditable && document.contains(lastEditable)) ? lastEditable : document.activeElement;
if (isEditable(el)) {
if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
const hasSel = el.selectionStart !== el.selectionEnd;
const text = hasSel ? el.value.slice(el.selectionStart, el.selectionEnd) : el.value;
capturedSelection = { mode: 'field', el, hasSel, start: el.selectionStart, end: el.selectionEnd };
return { mode: 'field', text };
} else {
// contenteditable
const sel = window.getSelection();
let hasSel = false, range = null, text = '';
if (sel && sel.rangeCount > 0 && !sel.isCollapsed && el.contains(sel.anchorNode)) {
range = sel.getRangeAt(0).cloneRange();
text = sel.toString();
hasSel = true;
} else {
text = el.innerText;
}
capturedSelection = { mode: 'ce', el, hasSel, range };
return { mode: 'ce', text };
}
}
// Unknown editor with no readable DOM text (custom canvas/WYSIWYG surface) —
// fall back to clipboard instead of just failing.
capturedSelection = { mode: 'clipboard' };
return { mode: 'clipboard' };
}
async function getClipboardText() {
try {
return await navigator.clipboard.readText();
} catch (err) {
throw new Error('Could not read clipboard. Copy your text first (Ctrl/Cmd+C), then click the action again.');
}
}
function dispatchInput(el) {
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
function applyResult(result) {
if (!capturedSelection) return;
if (capturedSelection.mode === 'field') {
const { el, hasSel, start, end } = capturedSelection;
if (hasSel) {
el.setRangeText(result, start, end, 'end');
} else {
el.value = result;
}
dispatchInput(el);
} else if (capturedSelection.mode === 'ce') {
const { el, hasSel, range } = capturedSelection;
if (hasSel && range) {
range.deleteContents();
range.insertNode(document.createTextNode(result));
} else {
el.innerText = result;
}
dispatchInput(el);
} else if (capturedSelection.mode === 'clipboard') {
GM_setClipboardSafe(result);
setStatus('Rewritten text copied — paste it back in with Ctrl/Cmd+V.', 'ok');
}
}
function GM_setClipboardSafe(text) {
if (typeof GM_setClipboard === 'function') {
GM_setClipboard(text);
} else {
navigator.clipboard.writeText(text).catch(() => {});
}
}
/* ---------------------------------------------------------------------
* PROMPTS
* ------------------------------------------------------------------- */
const INSTRUCTIONS = {
grammar: 'Fix all grammar, spelling, and punctuation mistakes in the text. Keep the original meaning, tone, and formatting intact. Return only the corrected text with no explanation, preamble, or quotation marks.',
punctuation: 'Fix only punctuation issues (commas, periods, apostrophes, quotation marks, etc.) in the text, leaving wording otherwise unchanged. Return only the corrected text, nothing else.',
clarity: 'Improve the clarity and conciseness of the text — tighten wording, remove redundancy and awkward phrasing — while preserving its original meaning and tone. Return only the rewritten text, nothing else.',
formal: 'Rewrite the text in a more formal, professional tone suitable for business or academic contexts. Preserve the original meaning. Return only the rewritten text, nothing else.',
informal: 'Rewrite the text in a more casual, conversational tone. Preserve the original meaning. Return only the rewritten text, nothing else.',
longer: 'Expand the text with more detail, explanation, or supporting points while preserving its original meaning and tone. Return only the rewritten text, nothing else.',
shorter: 'Make the text more concise and shorter while preserving its key meaning. Return only the rewritten text, nothing else.',
simplify: 'Simplify the text so it is easier to read, using plainer words and shorter sentences, while preserving its meaning. Return only the rewritten text, nothing else.',
humanize: 'Rewrite the text so it reads naturally, as if written by a person — vary sentence rhythm, remove robotic or repetitive AI-sounding phrasing — while preserving its original meaning and tone. Return only the rewritten text, nothing else.',
};
/* ---------------------------------------------------------------------
* API CALL
* ------------------------------------------------------------------- */
async function callModel(instruction, text) {
const apiKey = store.apiKey;
if (!apiKey) throw new Error('Add your API key in settings (⚙) first.');
const payload = JSON.stringify({
model: store.model,
temperature: 0.4,
max_tokens: 700,
messages: [
{ role: 'system', content: instruction },
{ role: 'user', content: text },
],
});
// Primary path: native fetch(). Avoids a known Tampermonkey bug where
// GM_xmlhttpRequest can silently drop custom headers (Authorization
// included) on POST requests that have a body.
try {
const res = await fetch(store.baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + apiKey,
},
body: payload,
});
const data = await res.json().catch(() => null);
if (!res.ok) {
throw new Error((data && data.error && data.error.message) || `Request failed (HTTP ${res.status}).`);
}
const content = data?.choices?.[0]?.message?.content?.trim();
if (!content) throw new Error('The model returned an empty response — this free model may have run out of budget "thinking." Try a specific free model instead of the router (see settings hint).');
return content;
} catch (fetchErr) {
// If fetch itself couldn't even reach the server (CORS/network block),
// fall back to GM_xmlhttpRequest, which bypasses page-level CORS.
if (!(fetchErr instanceof TypeError)) throw fetchErr; // real API error — don't mask it
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: store.baseUrl,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + apiKey,
},
data: payload,
onload: (res) => {
try {
const data = JSON.parse(res.responseText);
if (data.error) { reject(new Error(data.error.message || 'API error')); return; }
const content = data.choices?.[0]?.message?.content?.trim();
if (!content) { reject(new Error('No response from model.')); return; }
resolve(content);
} catch (e) {
reject(new Error('Could not parse API response.'));
}
},
onerror: () => reject(new Error('Network error calling the API (fetch and GM_xmlhttpRequest both failed).')),
});
});
}
}
/* ---------------------------------------------------------------------
* RUN ACTION
* ------------------------------------------------------------------- */
async function runAction(actionKey) {
els.output.style.display = 'none';
els.outputActions.classList.remove('hg-show');
setStatus('');
let captured;
try {
captured = (preCapture && preCapture.text && preCapture.text.trim()) ? preCapture : captureInput();
} catch (e) {
setStatus(e.message, 'error');
return;
} finally {
preCapture = null;
}
if (!captured) {
setStatus('No text field detected. Click/tap directly into the box you were typing in, then hit the action again.', 'error');
return;
}
let text = captured.text;
if (captured.mode === 'clipboard') {
setStatus('Reading clipboard… (select the text in the doc and copy it with Ctrl/Cmd+C first)');
try {
text = await getClipboardText();
} catch (e) {
setStatus(e.message, 'error');
return;
}
}
if (!text || !text.trim()) {
setStatus('Nothing to work with — select or focus some text first.', 'error');
return;
}
setStatus('Thinking… (free-tier models can take 15-30s, sometimes longer)');
const startedAt = Date.now();
try {
const result = await callModel(INSTRUCTIONS[actionKey], text);
const seconds = ((Date.now() - startedAt) / 1000).toFixed(1);
els.output.value = result;
els.output.style.display = 'block';
els.outputActions.classList.add('hg-show');
els.output.dataset.pending = result;
setStatus(`Done in ${seconds}s. Review below, then Apply or Copy.`, 'ok');
} catch (e) {
setStatus(e.message, 'error');
}
}
panel.querySelectorAll('.hg-grid button').forEach((btn) => {
btn.addEventListener('click', () => runAction(btn.dataset.action));
});
// Belt-and-suspenders: also snapshot on pointerdown over the panel, before the
// click even fires, in case a site's editor clears selection on any blur at all.
panel.addEventListener('pointerdown', () => {
try { preCapture = captureInput(); } catch (e) { preCapture = null; }
}, true);
let preCapture = null;
panel.querySelector('#hg-copy').addEventListener('click', () => {
GM_setClipboardSafe(els.output.value);
setStatus('Copied to clipboard.', 'ok');
});
panel.querySelector('#hg-apply').addEventListener('click', () => {
const result = els.output.value;
if (!result) return;
try {
applyResult(result);
if (capturedSelection?.mode !== 'clipboard') setStatus('Applied.', 'ok');
} catch (e) {
setStatus('Could not apply automatically — use Copy instead.', 'error');
}
});
window.addEventListener('resize', () => {
if (panel.classList.contains('hg-open')) positionPanel();
});
})();