New:Socket for Asana Is Now Available.Learn more
Get Started

Yunit – companion language layer

Package Overview
Versions
1
Alerts
File Explorer
Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

yunit@yunit.app - firefox Package Compare versions

Comparing version
0.2.2
to
0.4.0
+13
icons/icon.svg
<svg viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<!-- 96x96 burgundy tile inside a 128x128 canvas, leaving the
16-px transparent border CWS asks for. Corner radius scaled
proportionally (22/128 ≈ 17/96). -->
<rect x="16" y="16" width="96" height="96" rx="17" fill="#6b1924"/>
<!-- Y stroke, same triangle proportions, scaled 0.75 and offset
+16 to sit inside the 96-px tile. -->
<g stroke="#f5eee0" stroke-width="13.5" stroke-linecap="round" stroke-linejoin="round" fill="none">
<line x1="41.5" y1="41.5" x2="64" y2="68.5"/>
<line x1="86.5" y1="41.5" x2="64" y2="68.5"/>
<line x1="64" y1="68.5" x2="64" y2="91"/>
</g>
</svg>
+672
-0
(() => {
// extension/src/shared/config.js
var SUPABASE_URL = "https://jknixnkzcssdkxlarexm.supabase.co";
var SUPABASE_PUBLISHABLE_KEY = "sb_publishable_7Yc6YpRlGIVxilR2eVlRLA_8U667LjK";
var YUNIT_WEB_ORIGIN = "https://yunit.app";
var SHARP_TRANSLATE_URL = `${YUNIT_WEB_ORIGIN}/api/translate/sharp`;
var SONIOX_TEMP_KEY_URL = `${YUNIT_WEB_ORIGIN}/api/soniox/temp-key`;
var LIVE_MSG = {
START: "yunit:live:start",
STOP: "yunit:live:stop",
STATUS: "yunit:live:status",
SNAPSHOT: "yunit:live:snapshot",
// content asks for full session state (page reload)
RESET_TRANSCRIPT: "yunit:live:reset",
// content: SPA-navigated to a new video
OS_START: "yunit:live:os-start",
OS_STOP: "yunit:live:os-stop",
OS_SNAPSHOT: "yunit:live:os-snapshot",
OS_RESET: "yunit:live:os-reset",
KEY_REQUEST: "yunit:live:key-request",
EVENT: "yunit:live:event"
};
var AUTH_STORAGE_KEY = "yunit:auth-session";
var LOGIN_FLOW_MSG_TYPE = "yunit:login-flow-opened";
var AUTH_COMPLETED_MSG_TYPE = "yunit:auth-completed";
// extension/src/shared/auth.js
var REFRESH_MARGIN_MS = 3e4;
var listeners = /* @__PURE__ */ new Set();
var cached = null;
var cacheLoaded = false;
var refreshInFlight = null;
function notify() {
for (const cb of listeners) {
try {
cb(cached);
} catch (_) {
}
}
}
async function readFromStorage() {
return new Promise((resolve) => {
try {
chrome.storage.local.get(AUTH_STORAGE_KEY, (obj) => {
resolve(obj?.[AUTH_STORAGE_KEY] || null);
});
} catch (_) {
resolve(null);
}
});
}
async function writeToStorage(session) {
return new Promise((resolve) => {
try {
const payload = session ? { [AUTH_STORAGE_KEY]: session } : {};
if (session) {
chrome.storage.local.set(payload, () => resolve());
} else {
chrome.storage.local.remove(AUTH_STORAGE_KEY, () => resolve());
}
} catch (_) {
resolve();
}
});
}
async function ensureLoaded() {
if (cacheLoaded) return;
cached = await readFromStorage();
cacheLoaded = true;
}
async function clearSession() {
cached = null;
cacheLoaded = true;
await writeToStorage(null);
notify();
}
try {
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== "local" || !changes[AUTH_STORAGE_KEY]) return;
cached = changes[AUTH_STORAGE_KEY].newValue || null;
cacheLoaded = true;
notify();
});
} catch (_) {
}
async function refreshAccessToken() {
if (!cached?.refresh_token) return null;
if (refreshInFlight) return refreshInFlight;
refreshInFlight = (async () => {
try {
const res = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, {
method: "POST",
headers: {
apikey: SUPABASE_PUBLISHABLE_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify({ refresh_token: cached.refresh_token })
});
if (!res.ok) {
if (res.status === 400 || res.status === 401 || res.status === 403) {
await clearSession();
}
return null;
}
const data = await res.json();
const next = {
access_token: data.access_token,
refresh_token: data.refresh_token || cached.refresh_token,
expires_at: data.expires_at,
// unix seconds
user: data.user ? { id: data.user.id, email: data.user.email } : cached.user
};
cached = next;
await writeToStorage(cached);
notify();
return cached.access_token;
} catch (_) {
return null;
} finally {
refreshInFlight = null;
}
})();
return refreshInFlight;
}
async function getAccessToken() {
await ensureLoaded();
if (!cached?.access_token) return null;
const expSec = cached.expires_at || 0;
const now = Date.now();
const expMs = expSec * 1e3;
if (expMs - now > REFRESH_MARGIN_MS) return cached.access_token;
return await refreshAccessToken();
}
// extension/src/background/main.js

@@ -85,2 +218,450 @@ var STORAGE_KEY = "yunit.settings";

});
var OFFSCREEN_DOCUMENT_PATH = "offscreen.html";
var LIVE_SESSION_SECONDS = 15 * 60;
var liveSession = null;
var creatingOffscreen = null;
var liveStartInFlight = false;
function normalizeLang(lang) {
return typeof lang === "string" ? lang.trim().replace("_", "-").toLowerCase() : "";
}
function browserLang() {
try {
return (navigator.language || "en").split("-")[0].toLowerCase();
} catch (_) {
return "en";
}
}
function uniqueLangs(langs) {
const out = [];
const seen = /* @__PURE__ */ new Set();
for (const raw of langs || []) {
const lang = normalizeLang(raw);
if (!/^[a-z]{2,3}(?:-[a-z0-9]{2,8})?$/.test(lang)) continue;
if (seen.has(lang)) continue;
seen.add(lang);
out.push(lang);
}
return out.slice(0, 6);
}
function isYouTubeUrl(rawUrl) {
try {
const u = new URL(rawUrl || "");
const host = u.hostname.toLowerCase();
return host === "youtube.com" || host === "www.youtube.com" || host === "music.youtube.com" || host.endsWith(".youtube.com");
} catch (_) {
return false;
}
}
function liveCaptionsSupported() {
return !!(chrome?.tabCapture?.getMediaStreamId && chrome?.offscreen?.createDocument && chrome?.runtime?.getURL);
}
async function getActiveTab() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
return tab || null;
}
function getMediaStreamIdForTab(tabId) {
return new Promise((resolve, reject) => {
try {
chrome.tabCapture.getMediaStreamId({ targetTabId: tabId }, (streamId) => {
const err = chrome.runtime.lastError;
if (err) {
const raw = err.message || "Could not capture this tab.";
const needsInvoke = /invoked|activeTab/i.test(raw);
const e = new Error(needsInvoke ? "Press Alt+Shift+C once to allow captions on this tab." : raw);
if (needsInvoke) e.needsInvoke = true;
reject(e);
return;
}
if (!streamId) {
reject(new Error("Could not capture this tab."));
return;
}
resolve(streamId);
});
} catch (err) {
reject(err);
}
});
}
function sendRuntimeMessage(message) {
return new Promise((resolve, reject) => {
try {
chrome.runtime.sendMessage(message, (res) => {
const err = chrome.runtime.lastError;
if (err) {
reject(new Error(err.message || "Extension message failed."));
return;
}
resolve(res);
});
} catch (err) {
reject(err);
}
});
}
async function sendTabMessage(tabId, message) {
if (!tabId) return;
try {
await chrome.tabs.sendMessage(tabId, message);
} catch (_) {
}
}
async function hasOffscreenDocument() {
if (!chrome?.offscreen) return false;
const offscreenUrl = chrome.runtime.getURL(OFFSCREEN_DOCUMENT_PATH);
try {
if (chrome.runtime.getContexts) {
const contexts = await chrome.runtime.getContexts({
contextTypes: ["OFFSCREEN_DOCUMENT"],
documentUrls: [offscreenUrl]
});
return contexts.length > 0;
}
if (typeof clients !== "undefined" && clients.matchAll) {
const matched = await clients.matchAll();
return matched.some((client) => client.url === offscreenUrl);
}
} catch (_) {
}
return false;
}
async function ensureOffscreenDocument() {
if (!chrome?.offscreen?.createDocument) {
throw new Error("Live captions need Chrome or Edge on a computer.");
}
if (await hasOffscreenDocument()) return;
if (creatingOffscreen) {
await creatingOffscreen;
return;
}
creatingOffscreen = chrome.offscreen.createDocument({
url: OFFSCREEN_DOCUMENT_PATH,
reasons: ["USER_MEDIA"],
justification: "Capture tab audio for Premium live translated captions."
});
try {
await creatingOffscreen;
} finally {
creatingOffscreen = null;
}
}
async function closeOffscreenDocument() {
try {
if (await hasOffscreenDocument()) await chrome.offscreen.closeDocument();
} catch (_) {
}
}
async function fetchSonioxTempKey() {
const token = await getAccessToken();
if (!token) {
const err = new Error("Sign in to yunit to use live captions.");
err.fatal = true;
throw err;
}
const res = await fetch(SONIOX_TEMP_KEY_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
maxSessionDurationSeconds: LIVE_SESSION_SECONDS
})
});
let data = null;
try {
data = await res.json();
} catch (_) {
}
if (!res.ok) {
const allowanceUsed = res.status === 429 && data?.code === "allowance_exhausted";
const err = new Error(
res.status === 402 ? "Live captions are part of Premium." : res.status === 401 ? "Sign in again to use live captions." : allowanceUsed ? data?.tier === "free" ? "This month's free live captions preview is used up. Premium includes 15 hours a month." : "This month's live captions hours are used up. They reset next month." : data?.error || `Live captions backend returned ${res.status}.`
);
err.fatal = res.status === 401 || res.status === 402 || allowanceUsed;
throw err;
}
const apiKey = data?.apiKey || data?.api_key;
if (!apiKey) throw new Error("Live captions backend did not return a key.");
return {
apiKey,
maxSessionDurationSeconds: data?.maxSessionDurationSeconds || data?.max_session_duration_seconds || LIVE_SESSION_SECONDS
};
}
function langPlan(settings) {
const a = normalizeLang(settings.nativeLang) || browserLang() || "en";
let b = normalizeLang(settings.targetLang) || "";
if (b === a) b = "";
return {
translationTarget: a,
pair: { a, b: b || null },
hints: uniqueLangs([b, a])
};
}
function videoIdFromUrl(rawUrl) {
try {
return new URL(rawUrl || "").searchParams.get("v") || null;
} catch (_) {
return null;
}
}
function liveStatus() {
return {
supported: liveCaptionsSupported(),
active: !!liveSession,
state: liveSession?.state || "idle",
tabId: liveSession?.tabId || null,
pair: liveSession?.pair || null,
startedAt: liveSession?.startedAt || null
};
}
async function adoptFromOffscreen() {
if (liveSession) return liveSession;
if (!await hasOffscreenDocument()) return null;
try {
const snap = await sendRuntimeMessage({ type: LIVE_MSG.OS_SNAPSHOT, target: "offscreen" });
if (snap?.ok && snap.active) {
liveSession = {
sessionId: snap.sessionId,
tabId: snap.tabId,
state: snap.state || "live",
pair: snap.pair || null,
startedAt: snap.startedAt || Date.now()
};
return liveSession;
}
} catch (_) {
}
return null;
}
async function stopLive(reason = "Live captions stopped.") {
await adoptFromOffscreen();
const s = liveSession;
if (await hasOffscreenDocument()) {
try {
await sendRuntimeMessage({ type: LIVE_MSG.OS_STOP, target: "offscreen", reason });
} catch (_) {
if (s?.tabId) {
await sendTabMessage(s.tabId, {
type: LIVE_MSG.EVENT,
kind: "state",
state: "stopped",
message: reason
});
}
}
}
liveSession = null;
await closeOffscreenDocument();
return liveStatus();
}
async function startLive(msg, sender) {
if (!liveCaptionsSupported()) {
throw new Error("Live captions need Chrome or Edge on a computer.");
}
const tab = sender?.tab?.id ? sender.tab : await getActiveTab();
if (!tab?.id || !tab.url || !isYouTubeUrl(tab.url)) {
throw new Error("Open a YouTube video to use live captions.");
}
await adoptFromOffscreen();
if (liveSession) await stopLive("Live captions moved to another tab.");
const sessionId = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const settings = await readSettings();
const plan = langPlan(settings);
await sendTabMessage(tab.id, {
type: LIVE_MSG.EVENT,
kind: "state",
state: "starting",
sessionId,
pair: plan.pair
});
liveStartInFlight = true;
try {
const [tempKey] = await Promise.all([
fetchSonioxTempKey(),
ensureOffscreenDocument()
]);
const streamId = await getMediaStreamIdForTab(tab.id);
liveSession = {
sessionId,
tabId: tab.id,
state: "starting",
pair: plan.pair,
startedAt: Date.now()
};
const res = await sendRuntimeMessage({
type: LIVE_MSG.OS_START,
target: "offscreen",
sessionId,
tabId: tab.id,
streamId,
tempKey: tempKey.apiKey,
maxSessionDurationSeconds: tempKey.maxSessionDurationSeconds,
translationTarget: plan.translationTarget,
languageHints: plan.hints,
pair: plan.pair,
// Binds the session to the video it started on; the content script
// drops caption events whose context doesn't match its video, so
// a navigation can never leak the old transcript into the new one.
contextId: videoIdFromUrl(tab.url)
});
if (!res?.ok) throw new Error(res?.error || "Could not start live captions.");
liveSession.state = "live";
return liveStatus();
} catch (err) {
const error = err?.message || "Could not start live captions.";
if (liveSession?.sessionId === sessionId) liveSession = null;
await sendTabMessage(tab.id, {
type: LIVE_MSG.EVENT,
kind: "state",
state: "error",
message: error
});
if (!liveSession) await closeOffscreenDocument();
const out = new Error(error);
if (err?.needsInvoke) out.needsInvoke = true;
throw out;
} finally {
liveStartInFlight = false;
}
}
async function handleOffscreenEvent(msg) {
if (!liveSession && msg.tabId) {
liveSession = {
sessionId: msg.sessionId,
tabId: msg.tabId,
state: "live",
pair: msg.pair || null,
startedAt: msg.startedAt || Date.now()
};
}
const terminal = msg.kind === "state" && (msg.state === "stopped" || msg.state === "error");
if (liveSession && msg.sessionId && liveSession.sessionId !== msg.sessionId) {
if (terminal && msg.tabId) await sendTabMessage(msg.tabId, msg);
return;
}
const tabId = liveSession?.tabId || msg.tabId;
if (msg.kind === "state" && liveSession) liveSession.state = msg.state;
await sendTabMessage(tabId, msg);
if (terminal) {
liveSession = null;
setTimeout(async () => {
if (!liveSession && !liveStartInFlight) await closeOffscreenDocument();
}, 400);
}
}
async function handleLiveMessage(msg, sender, sendResponse) {
try {
switch (msg.type) {
case LIVE_MSG.STATUS: {
await adoptFromOffscreen();
sendResponse({ ok: true, ...liveStatus() });
return;
}
case LIVE_MSG.START: {
const status = await startLive(msg, sender);
sendResponse({ ok: true, ...status });
return;
}
case LIVE_MSG.STOP: {
const status = await stopLive(msg.reason || "Live captions stopped.");
sendResponse({ ok: true, ...status });
return;
}
case LIVE_MSG.KEY_REQUEST: {
if (sender?.tab) {
sendResponse({ ok: false, error: "not allowed", fatal: true });
return;
}
try {
const key = await fetchSonioxTempKey();
sendResponse({ ok: true, ...key });
} catch (err) {
sendResponse({ ok: false, error: err?.message || String(err), fatal: !!err?.fatal });
}
return;
}
case LIVE_MSG.SNAPSHOT: {
const s = await adoptFromOffscreen();
if (!s || sender?.tab?.id && s.tabId !== sender.tab.id) {
sendResponse({ ok: true, active: false });
return;
}
const snap = await sendRuntimeMessage({ type: LIVE_MSG.OS_SNAPSHOT, target: "offscreen" });
sendResponse(snap || { ok: true, active: false });
return;
}
case LIVE_MSG.RESET_TRANSCRIPT: {
const s = await adoptFromOffscreen();
if (s && sender?.tab?.id === s.tabId) {
try {
await sendRuntimeMessage({
type: LIVE_MSG.OS_RESET,
target: "offscreen",
contextId: msg.contextId || null
});
} catch (_) {
}
}
sendResponse({ ok: true });
return;
}
case LIVE_MSG.EVENT: {
await handleOffscreenEvent(msg);
sendResponse({ ok: true });
return;
}
default:
sendResponse({ ok: false, error: "unknown live message" });
}
} catch (err) {
try {
sendResponse({
ok: false,
error: err?.message || String(err),
needsInvoke: !!err?.needsInvoke
});
} catch (_) {
}
}
}
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (!msg || !Object.values(LIVE_MSG).includes(msg.type)) return false;
if (msg.target === "offscreen") return false;
handleLiveMessage(msg, sender, sendResponse);
return true;
});
try {
chrome.commands?.onCommand?.addListener(async (command) => {
if (command !== "toggle-live-captions") return;
if (!liveCaptionsSupported()) return;
await adoptFromOffscreen();
if (liveSession) {
await stopLive();
return;
}
try {
await startLive({}, null);
} catch (_) {
}
});
} catch (_) {
}
try {
chrome.tabs.onRemoved.addListener(async (tabId) => {
await adoptFromOffscreen();
if (liveSession?.tabId === tabId) {
stopLive("Live captions stopped: the tab closed.");
}
});
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo) => {
if (!changeInfo.url) return;
await adoptFromOffscreen();
if (!liveSession || liveSession.tabId !== tabId) return;
if (!isYouTubeUrl(changeInfo.url)) {
stopLive("Live captions stopped: the tab left YouTube.");
}
});
} catch (_) {
}
var OPEN_VOCAB_MSG_TYPE = "yunit:open-vocab";

@@ -100,2 +681,93 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {

});
var LOGIN_FLOW_KEY = "yunit:login-flow";
var LOGIN_FLOW_TTL_MS = 15 * 60 * 1e3;
var LOGIN_RETURN_DELAY_MS = 1200;
function readLoginFlow() {
return new Promise((resolve) => {
try {
chrome.storage.local.get(LOGIN_FLOW_KEY, (o) => {
void chrome.runtime.lastError;
const rec = o?.[LOGIN_FLOW_KEY];
resolve(rec && Date.now() - (rec.at || 0) <= LOGIN_FLOW_TTL_MS ? rec : null);
});
} catch (_) {
resolve(null);
}
});
}
function writeLoginFlow(rec) {
return new Promise((resolve) => {
try {
if (rec) {
chrome.storage.local.set({ [LOGIN_FLOW_KEY]: rec }, () => {
void chrome.runtime.lastError;
resolve();
});
} else {
chrome.storage.local.remove(LOGIN_FLOW_KEY, () => {
void chrome.runtime.lastError;
resolve();
});
}
} catch (_) {
resolve();
}
});
}
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg && msg.type === LOGIN_FLOW_MSG_TYPE) {
writeLoginFlow({
loginTabId: msg.loginTabId ?? null,
openerTabId: msg.openerTabId ?? null,
at: Date.now()
}).then(() => {
try {
sendResponse({ ok: true });
} catch (_) {
}
});
return true;
}
if (msg && msg.type === AUTH_COMPLETED_MSG_TYPE) {
const fromTabId = sender?.tab?.id;
(async () => {
const rec = await readLoginFlow();
if (!rec || fromTabId == null || rec.loginTabId !== fromTabId) return;
await writeLoginFlow(null);
setTimeout(async () => {
if (rec.openerTabId != null) {
try {
const tab = await chrome.tabs.get(rec.openerTabId);
try {
await chrome.windows.update(tab.windowId, { focused: true });
} catch (_) {
}
await chrome.tabs.update(rec.openerTabId, { active: true });
} catch (_) {
}
}
try {
await chrome.tabs.remove(fromTabId);
} catch (_) {
}
}, LOGIN_RETURN_DELAY_MS);
})().then(() => {
try {
sendResponse({ ok: true });
} catch (_) {
}
});
return true;
}
return false;
});
try {
chrome.tabs.onRemoved.addListener((tabId) => {
readLoginFlow().then((rec) => {
if (rec && rec.loginTabId === tabId) writeLoginFlow(null);
}).catch(() => {
});
});
} catch (_) {
}
})();
+3
-2
{
"manifest_version": 3,
"name": "Yunit \u2013 companion language layer",
"version": "0.2.2",
"version": "0.4.0",
"description": "Picks words from any page you read and quietly translates a few of them, so vocabulary travels with you.",
"permissions": [
"storage"
"storage",
"activeTab"
],

@@ -9,0 +10,0 @@ "host_permissions": [

@@ -90,2 +90,19 @@ <!doctype html>

}
/* Beta badge. Every version below 1.0 is beta, and the popup is the
surface every user sees most, so this is where the expectation
gets set: things still move. Sized to sit quietly beside the
wordmark rather than compete with the on/off pill. */
.brand .beta {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
font-size: 8.5px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
background: var(--accent-soft);
border-radius: 3px;
padding: 2px 5px;
position: relative;
top: -1px;
}

@@ -241,2 +258,87 @@ /* The on/off pill — doubles as the master enable toggle. Mirrors the

/* Premium live captions — shown only on YouTube tabs. It is a
command surface, not a persistent setting, so it sits between the
YouTube lyrics switch and the general dual-language control. */
.live-captions {
padding: 7px 0 3px;
}
.live-captions[hidden] { display: none; }
.live-toggle {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
min-height: 32px;
border: 1px solid var(--rule);
border-radius: 6px;
background: var(--bg-soft);
color: var(--ink);
font: inherit;
cursor: pointer;
padding: 6px 9px;
text-align: left;
transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease;
}
.live-toggle:hover:not([disabled]) {
border-color: hsla(355, 45%, 32%, 0.38);
color: var(--accent);
background: white;
}
.live-toggle[disabled] {
cursor: default;
color: var(--ink-dim);
background: transparent;
}
.live-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ink-dim);
flex-shrink: 0;
transition: background 0.2s ease, box-shadow 0.2s ease;
}
.live-toggle[data-active="true"] .live-dot {
background: var(--live);
box-shadow: 0 0 0 3px hsla(265, 60%, 50%, 0.14);
animation: live-dot-breathe 2.4s ease-in-out infinite;
}
@keyframes live-dot-breathe {
0%, 100% { box-shadow: 0 0 0 3px hsla(265, 60%, 50%, 0.14); }
50% { box-shadow: 0 0 0 5px hsla(265, 60%, 50%, 0.07); }
}
.live-label {
flex: 1;
min-width: 0;
font-weight: 500;
}
.live-target {
flex-shrink: 0;
color: var(--ink-dim);
font-size: 11px;
font-style: italic;
}
.live-status {
margin: 4px 1px 0;
min-height: 15px;
color: var(--ink-dim);
font-size: 11px;
line-height: 1.35;
font-style: italic;
}
.live-status[data-tone="error"] { color: var(--accent); }
/* Which streams to show — the one live-caption preference worth
having in the popup (everything visual is tuned on the video via
the panel's Aa menu, where the user can see the result). */
.live-show-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 1px 0;
}
.live-show-row[hidden] { display: none; }
.live-show-label {
color: var(--ink-mid);
font-size: 12px;
}
/* Density: 5 dots, fill-up style. Click to set. */

@@ -512,2 +614,39 @@ .dots {

/* First-sign-in import offer — asks before pushing words that were on
this device before the sign-in. Quiet card; accent only on the
affirmative action. */
.import-offer {
margin-top: 8px;
padding: 8px 10px;
border: 1px solid var(--rule);
border-radius: 6px;
background: var(--bg-soft);
}
.import-offer[hidden] { display: none; }
.import-offer p {
font-size: 11.5px;
color: var(--ink-mid);
line-height: 1.45;
margin-bottom: 6px;
}
.import-offer-actions {
display: flex;
align-items: baseline;
gap: 14px;
}
.import-offer-actions button {
background: none;
border: none;
padding: 0;
font: inherit;
font-size: 11.5px;
cursor: pointer;
transition: color 0.15s ease;
}
#importOfferAdd { color: var(--accent); font-weight: 600; }
#importOfferAdd:hover { color: var(--accent-hover); }
#importOfferAdd[disabled] { color: var(--ink-dim); cursor: default; }
#importOfferKeep { color: var(--ink-dim); }
#importOfferKeep:hover { color: var(--ink-mid); }
/* Footer link out to the orientation page on yunit.app. Quiet by

@@ -533,2 +672,27 @@ design — italic serif, ink-dim, sits below the account section. */

}
/* Hotkey hints — same quiet register as the footer, one muted line.
Populated by popup.js with only the shortcuts that exist in this
build and browser. */
.popup-shortcuts {
margin: 12px 16px 0;
text-align: center;
font-size: 10.5px;
line-height: 1.6;
color: var(--ink-dim);
}
.popup-shortcuts[hidden] { display: none; }
.popup-shortcuts kbd {
font-family: inherit;
font-size: 9.5px;
font-weight: 600;
letter-spacing: 0.2px;
color: var(--ink-dim);
background: transparent;
border: 1px solid var(--rule, hsl(28, 18%, 84%));
border-radius: 4px;
padding: 0.5px 4px 1px;
white-space: nowrap;
}
.popup-shortcuts .sep { margin: 0 5px; opacity: 0.55; }
</style>

@@ -566,2 +730,3 @@ </head>

<span class="name">yunit</span>
<span class="beta" title="yunit is in beta. Features, limits, and prices may still change.">beta</span>
</div>

@@ -644,2 +809,19 @@ <button class="state" id="enabledBtn" data-on="true" type="button" aria-label="Toggle yunit on or off">

<div class="live-captions" id="liveBox" hidden>
<button class="live-toggle" id="liveBtn" type="button" data-active="false">
<span class="live-dot" aria-hidden="true"></span>
<span class="live-label" id="liveLabel">Live captions</span>
<span class="live-target" id="liveMeta"></span>
</button>
<div class="live-show-row" id="liveShowRow" hidden>
<span class="live-show-label">Show</span>
<div class="seg" id="liveShowSeg" role="group" aria-label="Which caption streams to show">
<button data-show="both" type="button">Both</button>
<button data-show="original" type="button">Spoken</button>
<button data-show="translation" type="button">Translation</button>
</div>
</div>
<p class="live-status" id="liveStatus"></p>
</div>
<div class="row">

@@ -704,2 +886,9 @@ <span class="row-label">Dual language <span class="row-hint" data-hint="dual">Alt+Shift+D</span></span>

</p>
<div class="import-offer" id="importOffer" hidden>
<p id="importOfferText"></p>
<div class="import-offer-actions">
<button id="importOfferAdd" type="button">Add to my account</button>
<button id="importOfferKeep" type="button">Leave them off</button>
</div>
</div>
<div class="micro-actions">

@@ -713,2 +902,4 @@ <button id="upgradeBtn" type="button" style="display:none">Upgrade to Premium</button>

<p class="popup-shortcuts" id="popupShortcuts" hidden></p>
<p class="popup-foot">

@@ -715,0 +906,0 @@ <a href="https://yunit.app/welcome" target="_blank" rel="noopener">How yunit works</a>

+296
-1

@@ -7,2 +7,7 @@ (() => {

vocab: "yunit.vocab",
// Written atomically alongside every vocab write: { src, n, at }. `src`
// identifies the writing context so the other contexts can tell an
// external vocab change (adopt it) from their own write echoing back
// (ignore it). See vocab-store.js.
vocabRev: "yunit.vocab.rev",
// One-time pre-migration snapshot of the vocab. Presence acts as the

@@ -116,2 +121,30 @@ // "lemma migration done" flag. Kept indefinitely as a safety net.

lsSet(key, value);
},
// Multi-key write in ONE chrome.storage.local.set call, so the keys land
// atomically and arrive in a single onChanged event. Used by the vocab
// writer to pair the vocab blob with its revision stamp — two separate
// set() calls would let a reader observe one without the other.
async setMany(obj) {
if (extAlive() && chrome.storage && chrome.storage.local) {
return new Promise((resolve) => {
try {
chrome.storage.local.set(obj, () => {
try {
if (chrome.runtime && chrome.runtime.lastError) {
for (const k of Object.keys(obj)) lsSet(k, obj[k]);
}
} catch {
markExtDead();
for (const k of Object.keys(obj)) lsSet(k, obj[k]);
}
resolve();
});
} catch {
markExtDead();
for (const k of Object.keys(obj)) lsSet(k, obj[k]);
resolve();
}
});
}
for (const k of Object.keys(obj)) lsSet(k, obj[k]);
}

@@ -189,6 +222,266 @@ };

// 'small' | 'medium' | 'large'
lyricsOverlayNextLine: false
lyricsOverlayNextLine: false,
// Which streams the Premium live captions show, on the video and in
// the transcript panel. The visual appearance (contrast, color, size)
// deliberately reuses the lyricsOverlay* keys above: karaoke lines and
// live captions are one "text on video" surface, tuned once.
liveCaptionsShow: "both",
// 'both' | 'original' | 'translation'
// When the current line's translation may appear: 'instant' streams it
// in as it is produced; 'end' holds it until the sentence closes, for
// readers who prefer one settled translation over watching it
// assemble. Display-level only.
liveCaptionsTranslationTiming: "instant",
// 'instant' | 'end'
// Whether live captions paint on the video itself. 'hide' keeps a
// session's captions in the sidebar transcript only — the ✕ on the
// on-video overlay sets this (hiding beats stopping: a stopped session
// can't restart without a toolbar/hotkey invocation), and the panel's
// Aa menu turns it back on. Synced like every other setting.
liveCaptionsOnVideo: "show"
// 'show' | 'hide'
};
var settings = { ...DEFAULTS };
// extension/src/shared/config.js
var SUPABASE_URL = "https://jknixnkzcssdkxlarexm.supabase.co";
var SUPABASE_PUBLISHABLE_KEY = "sb_publishable_7Yc6YpRlGIVxilR2eVlRLA_8U667LjK";
var YUNIT_WEB_ORIGIN = "https://yunit.app";
var SHARP_TRANSLATE_URL = `${YUNIT_WEB_ORIGIN}/api/translate/sharp`;
var SONIOX_TEMP_KEY_URL = `${YUNIT_WEB_ORIGIN}/api/soniox/temp-key`;
var AUTH_STORAGE_KEY = "yunit:auth-session";
// extension/src/shared/auth.js
var REFRESH_MARGIN_MS = 3e4;
var listeners = /* @__PURE__ */ new Set();
var cached = null;
var cacheLoaded = false;
var refreshInFlight = null;
function notify() {
for (const cb of listeners) {
try {
cb(cached);
} catch (_) {
}
}
}
async function readFromStorage() {
return new Promise((resolve) => {
try {
chrome.storage.local.get(AUTH_STORAGE_KEY, (obj) => {
resolve(obj?.[AUTH_STORAGE_KEY] || null);
});
} catch (_) {
resolve(null);
}
});
}
async function writeToStorage(session) {
return new Promise((resolve) => {
try {
const payload = session ? { [AUTH_STORAGE_KEY]: session } : {};
if (session) {
chrome.storage.local.set(payload, () => resolve());
} else {
chrome.storage.local.remove(AUTH_STORAGE_KEY, () => resolve());
}
} catch (_) {
resolve();
}
});
}
async function ensureLoaded() {
if (cacheLoaded) return;
cached = await readFromStorage();
cacheLoaded = true;
}
async function getSession() {
await ensureLoaded();
return cached;
}
async function clearSession() {
cached = null;
cacheLoaded = true;
await writeToStorage(null);
notify();
}
function onAuthChange(cb) {
listeners.add(cb);
return () => listeners.delete(cb);
}
try {
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== "local" || !changes[AUTH_STORAGE_KEY]) return;
cached = changes[AUTH_STORAGE_KEY].newValue || null;
cacheLoaded = true;
notify();
});
} catch (_) {
}
async function refreshAccessToken() {
if (!cached?.refresh_token) return null;
if (refreshInFlight) return refreshInFlight;
refreshInFlight = (async () => {
try {
const res = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, {
method: "POST",
headers: {
apikey: SUPABASE_PUBLISHABLE_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify({ refresh_token: cached.refresh_token })
});
if (!res.ok) {
if (res.status === 400 || res.status === 401 || res.status === 403) {
await clearSession();
}
return null;
}
const data = await res.json();
const next = {
access_token: data.access_token,
refresh_token: data.refresh_token || cached.refresh_token,
expires_at: data.expires_at,
// unix seconds
user: data.user ? { id: data.user.id, email: data.user.email } : cached.user
};
cached = next;
await writeToStorage(cached);
notify();
return cached.access_token;
} catch (_) {
return null;
} finally {
refreshInFlight = null;
}
})();
return refreshInFlight;
}
async function getAccessToken() {
await ensureLoaded();
if (!cached?.access_token) return null;
const expSec = cached.expires_at || 0;
const now = Date.now();
const expMs = expSec * 1e3;
if (expMs - now > REFRESH_MARGIN_MS) return cached.access_token;
return await refreshAccessToken();
}
// extension/src/shared/settings-sync.js
var SYNC_STATE_KEY = "yunit:settings-sync-state";
var WAKE_PULL_THROTTLE_MS = 5 * 60 * 1e3;
var pullInFlight = null;
async function getSyncState() {
return new Promise((resolve) => {
try {
chrome.storage.local.get(SYNC_STATE_KEY, (o) => resolve(o?.[SYNC_STATE_KEY] || {}));
} catch (_) {
resolve({});
}
});
}
async function setSyncState(patch) {
const cur = await getSyncState();
return new Promise((resolve) => {
try {
chrome.storage.local.set({ [SYNC_STATE_KEY]: { ...cur, ...patch } }, () => resolve());
} catch (_) {
resolve();
}
});
}
async function authedHeaders(extra = {}) {
const token = await getAccessToken();
if (!token) throw new Error("not-authenticated");
return {
apikey: SUPABASE_PUBLISHABLE_KEY,
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...extra
};
}
async function pushSettings() {
const session = await getSession();
if (!session?.user?.id) return false;
const settings2 = await storage.get(STORAGE_KEYS.settings);
if (!settings2) return false;
try {
const headers = await authedHeaders({
Prefer: "resolution=merge-duplicates,return=representation"
});
const res = await fetch(
`${SUPABASE_URL}/rest/v1/account_settings?on_conflict=user_id`,
{
method: "POST",
headers,
body: JSON.stringify([{ user_id: session.user.id, settings: settings2 }])
}
);
if (!res.ok) {
if (typeof console !== "undefined") console.warn("[yunit-settings-sync] push", res.status);
return false;
}
const rows = await res.json();
const updatedAt = rows?.[0]?.updated_at;
if (updatedAt) await setSyncState({ lastSyncedAt: updatedAt, userId: session.user.id });
return true;
} catch (err) {
if (typeof console !== "undefined") console.warn("[yunit-settings-sync] push", err?.message || err);
return false;
}
}
async function pullSettings({ force = false } = {}) {
if (pullInFlight) return pullInFlight;
pullInFlight = (async () => {
const session = await getSession();
if (!session?.user?.id) return false;
try {
const headers = await authedHeaders();
const params = new URLSearchParams({
select: "settings,updated_at",
user_id: `eq.${session.user.id}`
});
const res = await fetch(`${SUPABASE_URL}/rest/v1/account_settings?${params}`, { headers });
if (!res.ok) return false;
const rows = await res.json();
const row = Array.isArray(rows) ? rows[0] : null;
if (!row) return false;
const state = await getSyncState();
const switchedUser = state.userId !== session.user.id;
const cloudMs = new Date(row.updated_at).getTime();
const localMs = state.lastSyncedAt ? new Date(state.lastSyncedAt).getTime() : 0;
const shouldApply = force || switchedUser || !state.lastSyncedAt || cloudMs > localMs;
if (!shouldApply) return false;
await storage.set(STORAGE_KEYS.settings, row.settings || {});
await setSyncState({ lastSyncedAt: row.updated_at, userId: session.user.id });
return true;
} catch (err) {
if (typeof console !== "undefined") console.warn("[yunit-settings-sync] pull", err?.message || err);
return false;
} finally {
pullInFlight = null;
}
})();
return pullInFlight;
}
onAuthChange(async (session) => {
if (!session) return;
const applied = await pullSettings();
if (!applied) await pushSettings();
});
async function claimWakePull() {
const state = await getSyncState();
const last = state.lastWakePullAt || 0;
if (last && Date.now() - last < WAKE_PULL_THROTTLE_MS) return false;
await setSyncState({ lastWakePullAt: Date.now() });
return true;
}
(async () => {
try {
const session = await getSession();
if (session?.user?.id && await claimWakePull()) await pullSettings();
} catch (_) {
}
})();
// extension/src/sites/main.js

@@ -207,2 +500,4 @@ var $ = (id) => document.getElementById(id);

await storage.set(STORAGE_KEYS.settings, { ...cur, ...patch });
pushSettings().catch(() => {
});
}

@@ -209,0 +504,0 @@ function activeListKey(mode) {

@@ -560,2 +560,97 @@ <!doctype html>

/* ---- Review session ---- */
/* Same commonplace register as the entries: hairline-framed card,
large serif word, italic accent translation. No timers, no
progress bars — a quiet stack of cards. */
.intro .review-link {
background: none;
border: none;
padding: 0;
font: inherit;
font-style: italic;
color: var(--accent);
cursor: pointer;
border-bottom: 1px solid var(--accent);
transition: opacity 0.15s ease;
}
.intro .review-link:hover { opacity: 0.75; }
.review { margin: 4px 0 26px; }
.review-head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 16px;
}
.review-progress {
font-family: var(--sans);
font-size: 10px;
font-weight: 600;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--accent);
}
.review-exit {
background: none;
border: none;
padding: 0;
font-family: var(--serif);
font-style: italic;
font-size: 14px;
color: var(--ink-dim);
cursor: pointer;
transition: color 0.15s ease;
}
.review-exit:hover { color: var(--ink); }
.review-card {
border-top: 1px solid var(--rule);
border-bottom: 1px solid var(--rule);
padding: 32px 0 24px;
}
.review-word-line { display: flex; align-items: baseline; gap: 10px; }
.review-word {
font-family: var(--serif);
font-size: 42px;
line-height: 1.1;
letter-spacing: -0.4px;
color: var(--ink);
}
.review-quote { margin-top: 16px; }
.review-translation {
font-family: var(--serif);
font-style: italic;
font-size: 22px;
color: var(--accent);
margin: 20px 0 0;
}
.review-actions { margin-top: 26px; display: flex; gap: 12px; }
.review-actions button {
font-family: var(--sans);
font-size: 12px;
letter-spacing: 0.4px;
padding: 8px 18px;
border-radius: 999px;
border: 1px solid var(--rule);
background: var(--bg);
color: var(--ink-mid);
cursor: pointer;
transition: color 0.15s ease, border-color 0.15s ease;
}
.review-actions button:hover { color: var(--ink); border-color: var(--ink-dim); }
.review-knew:hover { color: var(--accent) !important; border-color: var(--accent) !important; }
.review-hint {
font-family: var(--serif);
font-style: italic;
font-size: 12.5px;
color: var(--ink-dim);
margin: 16px 0 0;
}
.review-done {
font-family: var(--serif);
font-size: 20px;
color: var(--ink-mid);
margin: 4px 0;
}
.review-done strong { color: var(--ink); font-weight: 400; }
/* Subtle responsive */

@@ -576,3 +671,3 @@ @media (max-width: 520px) {

<header>
<div class="eyebrow">✱ yunit · a reader's lexicon</div>
<div class="eyebrow">✱ yunit · a reader's lexicon · beta</div>
<h1 id="pageTitle">A reader's lexicon.</h1>

@@ -584,2 +679,27 @@ <p class="intro" id="intro">Loading…</p>

<!-- Review session — the guaranteed channel for due words. Hidden
until entered from the intro's "Review them" link; while active
it replaces the browsing chrome below. -->
<section class="review" id="review" hidden>
<div class="review-head">
<span class="review-progress" id="reviewProgress"></span>
<button class="review-exit" id="reviewExit" type="button">end review</button>
</div>
<div class="review-card">
<div class="review-word-line" id="reviewWordLine">
<span class="review-word" id="reviewWord"></span>
<button class="speak review-speak" id="reviewSpeak" title="Pronounce" hidden>▸</button>
</div>
<blockquote class="quote review-quote" id="reviewContext" hidden></blockquote>
<p class="review-translation" id="reviewTranslation" hidden></p>
<div class="review-actions" id="reviewActions">
<button class="review-reveal" id="reviewReveal" type="button">reveal</button>
<button class="review-knew" id="reviewKnew" type="button" hidden>✓ knew it</button>
<button class="review-forgot" id="reviewForgot" type="button" hidden>✗ forgot</button>
</div>
<p class="review-hint" id="reviewHint">space to reveal · esc to leave</p>
<p class="review-done" id="reviewDone" hidden></p>
</div>
</section>
<hr class="section-rule">

@@ -586,0 +706,0 @@

@@ -68,2 +68,20 @@ <!doctype html>

/* Beta note. First run is the right moment to set the expectation
that things still move, so the reader takes later changes well
and writes to us when something is rough. */
.beta-note {
font-family: var(--serif);
font-style: italic;
font-size: 14.5px;
line-height: 1.6;
color: var(--ink-dim);
margin: -18px 0 32px;
max-width: 520px;
}
.beta-note a {
color: var(--accent);
text-decoration: none;
border-bottom: 1px solid var(--accent);
}
.section-rule {

@@ -174,3 +192,3 @@ border: none;

<div class="page">
<p class="eyebrow">✱ yunit</p>
<p class="eyebrow">✱ yunit · beta</p>
<h1>A reader's lexicon.</h1>

@@ -182,2 +200,8 @@ <p class="lede">

</p>
<p class="beta-note">
yunit is in beta. Everything here works today, and it is all still
changing. If something is rough or missing, write to
<a href="mailto:support@yunit.app">support@yunit.app</a>. A person
reads every message, and beta feedback decides what gets built next.
</p>

@@ -184,0 +208,0 @@ <hr class="section-rule">

@@ -7,2 +7,7 @@ (() => {

vocab: "yunit.vocab",
// Written atomically alongside every vocab write: { src, n, at }. `src`
// identifies the writing context so the other contexts can tell an
// external vocab change (adopt it) from their own write echoing back
// (ignore it). See vocab-store.js.
vocabRev: "yunit.vocab.rev",
// One-time pre-migration snapshot of the vocab. Presence acts as the

@@ -116,2 +121,30 @@ // "lemma migration done" flag. Kept indefinitely as a safety net.

lsSet(key, value);
},
// Multi-key write in ONE chrome.storage.local.set call, so the keys land
// atomically and arrive in a single onChanged event. Used by the vocab
// writer to pair the vocab blob with its revision stamp — two separate
// set() calls would let a reader observe one without the other.
async setMany(obj) {
if (extAlive() && chrome.storage && chrome.storage.local) {
return new Promise((resolve) => {
try {
chrome.storage.local.set(obj, () => {
try {
if (chrome.runtime && chrome.runtime.lastError) {
for (const k of Object.keys(obj)) lsSet(k, obj[k]);
}
} catch {
markExtDead();
for (const k of Object.keys(obj)) lsSet(k, obj[k]);
}
resolve();
});
} catch {
markExtDead();
for (const k of Object.keys(obj)) lsSet(k, obj[k]);
resolve();
}
});
}
for (const k of Object.keys(obj)) lsSet(k, obj[k]);
}

@@ -189,6 +222,30 @@ };

// 'small' | 'medium' | 'large'
lyricsOverlayNextLine: false
lyricsOverlayNextLine: false,
// Which streams the Premium live captions show, on the video and in
// the transcript panel. The visual appearance (contrast, color, size)
// deliberately reuses the lyricsOverlay* keys above: karaoke lines and
// live captions are one "text on video" surface, tuned once.
liveCaptionsShow: "both",
// 'both' | 'original' | 'translation'
// When the current line's translation may appear: 'instant' streams it
// in as it is produced; 'end' holds it until the sentence closes, for
// readers who prefer one settled translation over watching it
// assemble. Display-level only.
liveCaptionsTranslationTiming: "instant",
// 'instant' | 'end'
// Whether live captions paint on the video itself. 'hide' keeps a
// session's captions in the sidebar transcript only — the ✕ on the
// on-video overlay sets this (hiding beats stopping: a stopped session
// can't restart without a toolbar/hotkey invocation), and the panel's
// Aa menu turns it back on. Synced like every other setting.
liveCaptionsOnVideo: "show"
// 'show' | 'hide'
};
var settings = { ...DEFAULTS };
// extension/src/shared/config.js
var YUNIT_WEB_ORIGIN = "https://yunit.app";
var SHARP_TRANSLATE_URL = `${YUNIT_WEB_ORIGIN}/api/translate/sharp`;
var SONIOX_TEMP_KEY_URL = `${YUNIT_WEB_ORIGIN}/api/soniox/temp-key`;
// extension/src/welcome/main.js

@@ -219,3 +276,3 @@ var $ = (id) => document.getElementById(id);

try {
window.location.href = "https://yunit.app/welcome";
window.location.href = `${YUNIT_WEB_ORIGIN}/welcome`;
} catch (_) {

@@ -222,0 +279,0 @@ window.close();

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display