@mxml3gend/gloss 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gloss Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # Gloss CLI
2
+
3
+ Gloss is a local-first translation editor for JSON i18n files.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ npx gloss
9
+ ```
10
+
11
+ Or install as a dev dependency:
12
+
13
+ ```bash
14
+ npm install -D gloss
15
+ npx gloss
16
+ ```
17
+
18
+ Project script:
19
+
20
+ ```json
21
+ {
22
+ "scripts": {
23
+ "gloss": "gloss"
24
+ }
25
+ }
26
+ ```
27
+
28
+ ## Configuration
29
+
30
+ Create `gloss.config.ts` in your project root:
31
+
32
+ ```ts
33
+ export default {
34
+ locales: ["en", "nl"],
35
+ defaultLocale: "en",
36
+ path: "src/i18n",
37
+ format: "json",
38
+ };
39
+ ```
40
+
41
+ For CommonJS projects, create `gloss.config.cjs`:
42
+
43
+ ```js
44
+ module.exports = {
45
+ locales: ["en", "nl"],
46
+ defaultLocale: "en",
47
+ path: "src/i18n",
48
+ format: "json",
49
+ };
50
+ ```
51
+
52
+ ## Options
53
+
54
+ ```bash
55
+ gloss --help
56
+ gloss --version
57
+ gloss --no-open
58
+ gloss --port 5179
59
+ ```
package/dist/config.js ADDED
@@ -0,0 +1,141 @@
1
+ import fs from "node:fs/promises";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ export class GlossConfigError extends Error {
6
+ code;
7
+ constructor(code, message) {
8
+ super(message);
9
+ this.code = code;
10
+ this.name = "GlossConfigError";
11
+ }
12
+ }
13
+ const normalizeScanPatterns = (value) => {
14
+ if (!Array.isArray(value)) {
15
+ return undefined;
16
+ }
17
+ const next = value
18
+ .filter((entry) => typeof entry === "string")
19
+ .map((entry) => entry.trim())
20
+ .filter((entry) => entry.length > 0);
21
+ return next.length > 0 ? next : undefined;
22
+ };
23
+ const normalizeScanConfig = (value) => {
24
+ if (!value || typeof value !== "object") {
25
+ return undefined;
26
+ }
27
+ const scan = value;
28
+ const include = normalizeScanPatterns(scan.include);
29
+ const exclude = normalizeScanPatterns(scan.exclude);
30
+ if (!include && !exclude) {
31
+ return undefined;
32
+ }
33
+ return { include, exclude };
34
+ };
35
+ const CONFIG_FILE_NAMES = [
36
+ "gloss.config.ts",
37
+ "gloss.config.mts",
38
+ "gloss.config.js",
39
+ "gloss.config.mjs",
40
+ "gloss.config.cjs",
41
+ ];
42
+ const resolveLocalesDirectory = (cwd, localesPath) => {
43
+ if (path.isAbsolute(localesPath)) {
44
+ return localesPath;
45
+ }
46
+ return path.join(cwd, localesPath);
47
+ };
48
+ const discoverLocales = async (cwd, localesPath) => {
49
+ const directory = resolveLocalesDirectory(cwd, localesPath);
50
+ try {
51
+ const entries = await fs.readdir(directory, { withFileTypes: true });
52
+ return entries
53
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
54
+ .map((entry) => path.basename(entry.name, ".json").trim())
55
+ .filter((entry) => entry.length > 0)
56
+ .sort((a, b) => a.localeCompare(b));
57
+ }
58
+ catch {
59
+ return [];
60
+ }
61
+ };
62
+ const resolveConfigPath = async (cwd) => {
63
+ for (const fileName of CONFIG_FILE_NAMES) {
64
+ const candidatePath = path.join(cwd, fileName);
65
+ try {
66
+ await fs.access(candidatePath);
67
+ return candidatePath;
68
+ }
69
+ catch {
70
+ continue;
71
+ }
72
+ }
73
+ return null;
74
+ };
75
+ const normalizeLoadedConfig = (value) => {
76
+ if (value && typeof value === "object" && "default" in value) {
77
+ return value.default;
78
+ }
79
+ return value;
80
+ };
81
+ export async function loadGlossConfig() {
82
+ const cwd = process.env.INIT_CWD || process.cwd();
83
+ const configPath = await resolveConfigPath(cwd);
84
+ if (!configPath) {
85
+ throw new GlossConfigError("MISSING_CONFIG", `Missing config in ${cwd}. Expected one of: ${CONFIG_FILE_NAMES.join(", ")}.`);
86
+ }
87
+ try {
88
+ const extension = path.extname(configPath).toLowerCase();
89
+ const loaded = extension === ".cjs"
90
+ ? createRequire(import.meta.url)(configPath)
91
+ : await import(pathToFileURL(configPath).href);
92
+ const cfg = normalizeLoadedConfig(loaded);
93
+ if (!cfg || typeof cfg !== "object") {
94
+ throw new GlossConfigError("INVALID_CONFIG", "Default export must be a config object.");
95
+ }
96
+ if (typeof cfg.path !== "string" || !cfg.path.trim()) {
97
+ throw new GlossConfigError("INVALID_CONFIG", "`path` must be a non-empty string.");
98
+ }
99
+ const translationsPath = cfg.path.trim();
100
+ if (cfg.locales !== undefined && !Array.isArray(cfg.locales)) {
101
+ throw new GlossConfigError("INVALID_CONFIG", "`locales` must be an array of locale codes when provided.");
102
+ }
103
+ const configuredLocales = Array.isArray(cfg.locales)
104
+ ? cfg.locales
105
+ .map((entry) => (typeof entry === "string" ? entry.trim() : ""))
106
+ .filter((entry) => entry.length > 0)
107
+ : [];
108
+ const locales = configuredLocales.length > 0
109
+ ? configuredLocales
110
+ : await discoverLocales(cwd, translationsPath);
111
+ if (locales.length === 0) {
112
+ throw new GlossConfigError("NO_LOCALES", `No locales found. Add "locales" in config or place *.json files in ${translationsPath}.`);
113
+ }
114
+ if (typeof cfg.defaultLocale !== "string" || !cfg.defaultLocale.trim()) {
115
+ throw new GlossConfigError("INVALID_CONFIG", "`defaultLocale` must be a non-empty string.");
116
+ }
117
+ const defaultLocale = cfg.defaultLocale.trim();
118
+ if (!locales.includes(defaultLocale)) {
119
+ throw new GlossConfigError("INVALID_CONFIG", "`defaultLocale` must be included in `locales`.");
120
+ }
121
+ return {
122
+ ...cfg,
123
+ locales,
124
+ defaultLocale,
125
+ path: translationsPath,
126
+ format: "json",
127
+ scan: normalizeScanConfig(cfg.scan),
128
+ };
129
+ }
130
+ catch (e) {
131
+ if (e instanceof GlossConfigError) {
132
+ throw e;
133
+ }
134
+ const message = e.message;
135
+ if (path.extname(configPath).toLowerCase() === ".ts" &&
136
+ /Unexpected token 'export'/.test(message)) {
137
+ throw new GlossConfigError("INVALID_CONFIG", "Could not parse gloss.config.ts in CommonJS mode. Use `module.exports = { ... }`, rename to gloss.config.cjs, or set package.json `type` to `module`.");
138
+ }
139
+ throw new GlossConfigError("INVALID_CONFIG", `Invalid ${path.basename(configPath)}: ${message}`);
140
+ }
141
+ }
@@ -0,0 +1,11 @@
1
+ import { loadGlossConfig } from "./config.js";
2
+ import { startServer } from "./server.js";
3
+ async function main() {
4
+ const cfg = await loadGlossConfig();
5
+ const { port } = await startServer(cfg, 5179);
6
+ console.log(`Gloss API running at http://localhost:${port}`);
7
+ }
8
+ main().catch((error) => {
9
+ console.error(error);
10
+ process.exit(1);
11
+ });
package/dist/fs.js ADDED
@@ -0,0 +1,37 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ function projectRoot() {
4
+ return process.env.INIT_CWD || process.cwd();
5
+ }
6
+ function translationsDir(cfg) {
7
+ if (path.isAbsolute(cfg.path)) {
8
+ return cfg.path;
9
+ }
10
+ return path.join(projectRoot(), cfg.path);
11
+ }
12
+ function localeFile(cfg, locale) {
13
+ return path.join(translationsDir(cfg), `${locale}.json`);
14
+ }
15
+ export async function readAllTranslations(cfg) {
16
+ const out = {};
17
+ for (const locale of cfg.locales) {
18
+ const file = localeFile(cfg, locale);
19
+ try {
20
+ const raw = await fs.readFile(file, "utf8");
21
+ out[locale] = JSON.parse(raw);
22
+ }
23
+ catch {
24
+ out[locale] = {}; // start empty if missing
25
+ }
26
+ }
27
+ return out;
28
+ }
29
+ export async function writeAllTranslations(cfg, data) {
30
+ const dir = translationsDir(cfg);
31
+ await fs.mkdir(dir, { recursive: true });
32
+ for (const locale of cfg.locales) {
33
+ const file = localeFile(cfg, locale);
34
+ const json = JSON.stringify(data[locale] ?? {}, null, 2) + "\n";
35
+ await fs.writeFile(file, json, "utf8");
36
+ }
37
+ }
package/dist/index.js ADDED
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs/promises";
3
+ import { fileURLToPath } from "node:url";
4
+ import open from "open";
5
+ import { GlossConfigError, loadGlossConfig } from "./config.js";
6
+ import { startServer } from "./server.js";
7
+ const DEFAULT_PORT = 5179;
8
+ const getVersion = async () => {
9
+ const packagePath = fileURLToPath(new URL("../package.json", import.meta.url));
10
+ const raw = await fs.readFile(packagePath, "utf8");
11
+ const pkg = JSON.parse(raw);
12
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
13
+ };
14
+ const printHelp = () => {
15
+ console.log(`Gloss
16
+
17
+ Usage:
18
+ gloss [options]
19
+
20
+ Options:
21
+ -h, --help Show help
22
+ -v, --version Show version
23
+ --no-open Do not open browser automatically
24
+ -p, --port Set server port (default: ${DEFAULT_PORT})
25
+ `);
26
+ };
27
+ const parseArgs = (args) => {
28
+ const options = {
29
+ help: false,
30
+ version: false,
31
+ noOpen: false,
32
+ port: DEFAULT_PORT,
33
+ };
34
+ for (let index = 0; index < args.length; index += 1) {
35
+ const arg = args[index];
36
+ if (arg === "-h" || arg === "--help") {
37
+ options.help = true;
38
+ continue;
39
+ }
40
+ if (arg === "-v" || arg === "--version") {
41
+ options.version = true;
42
+ continue;
43
+ }
44
+ if (arg === "--no-open") {
45
+ options.noOpen = true;
46
+ continue;
47
+ }
48
+ if (arg === "-p" || arg === "--port") {
49
+ const nextValue = args[index + 1];
50
+ if (!nextValue) {
51
+ throw new Error("Missing value for --port.");
52
+ }
53
+ const parsed = Number.parseInt(nextValue, 10);
54
+ if (!Number.isFinite(parsed) || parsed <= 0) {
55
+ throw new Error("Port must be a positive integer.");
56
+ }
57
+ options.port = parsed;
58
+ index += 1;
59
+ continue;
60
+ }
61
+ throw new Error(`Unknown argument: ${arg}`);
62
+ }
63
+ return options;
64
+ };
65
+ const printConfigError = (error) => {
66
+ if (error.code === "MISSING_CONFIG") {
67
+ console.error("Gloss could not start: config file was not found.");
68
+ console.error("Create one of: gloss.config.ts, gloss.config.mts, gloss.config.js, gloss.config.mjs, gloss.config.cjs.");
69
+ return;
70
+ }
71
+ if (error.code === "NO_LOCALES") {
72
+ console.error("Gloss could not start: no locales are configured.");
73
+ console.error("Set `locales: [\"en\", ...]` or place locale JSON files in your configured path.");
74
+ console.error(error.message);
75
+ return;
76
+ }
77
+ console.error("Gloss could not start: invalid gloss.config.ts.");
78
+ console.error(error.message);
79
+ };
80
+ async function main() {
81
+ const options = parseArgs(process.argv.slice(2));
82
+ if (options.help) {
83
+ printHelp();
84
+ return;
85
+ }
86
+ if (options.version) {
87
+ console.log(await getVersion());
88
+ return;
89
+ }
90
+ const cfg = await loadGlossConfig();
91
+ const { port } = await startServer(cfg, options.port);
92
+ const url = `http://localhost:${port}`;
93
+ console.log(`Gloss running at ${url}`);
94
+ if (options.noOpen || process.env.CI) {
95
+ return;
96
+ }
97
+ try {
98
+ await open(url);
99
+ }
100
+ catch (error) {
101
+ console.error(`Could not open browser automatically: ${error.message}`);
102
+ console.error(`Open ${url} manually.`);
103
+ }
104
+ }
105
+ main().catch((e) => {
106
+ if (e instanceof GlossConfigError) {
107
+ printConfigError(e);
108
+ process.exit(1);
109
+ }
110
+ console.error(e instanceof Error ? e.message : String(e));
111
+ printHelp();
112
+ process.exit(1);
113
+ });
@@ -0,0 +1,66 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ const IGNORED_DIRECTORIES = new Set([
4
+ "node_modules",
5
+ "dist",
6
+ "build",
7
+ ".git",
8
+ "coverage",
9
+ ]);
10
+ const SCANNED_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx"]);
11
+ const TRANSLATION_CALL_REGEX = /(\b(?:t|translate)\s*\(\s*)(['"])([^'"]+)\2/g;
12
+ const projectRoot = () => process.env.INIT_CWD || process.cwd();
13
+ const normalizePath = (filePath) => filePath.split(path.sep).join("/");
14
+ const isScannableFile = (fileName) => SCANNED_EXTENSIONS.has(path.extname(fileName));
15
+ export async function renameKeyUsage(oldKey, newKey, rootDir = projectRoot()) {
16
+ if (!oldKey || !newKey || oldKey === newKey) {
17
+ return {
18
+ changedFiles: [],
19
+ filesScanned: 0,
20
+ replacements: 0,
21
+ };
22
+ }
23
+ const changedFiles = [];
24
+ let filesScanned = 0;
25
+ let replacements = 0;
26
+ const scanDirectory = async (directory) => {
27
+ const entries = await fs.readdir(directory, { withFileTypes: true });
28
+ for (const entry of entries) {
29
+ const fullPath = path.join(directory, entry.name);
30
+ if (entry.isDirectory()) {
31
+ if (IGNORED_DIRECTORIES.has(entry.name)) {
32
+ continue;
33
+ }
34
+ await scanDirectory(fullPath);
35
+ continue;
36
+ }
37
+ if (!entry.isFile() || !isScannableFile(entry.name)) {
38
+ continue;
39
+ }
40
+ filesScanned += 1;
41
+ const source = await fs.readFile(fullPath, "utf8");
42
+ let fileReplacements = 0;
43
+ const updated = source.replace(TRANSLATION_CALL_REGEX, (match, prefix, quote, key) => {
44
+ if (key !== oldKey) {
45
+ return match;
46
+ }
47
+ fileReplacements += 1;
48
+ replacements += 1;
49
+ return `${prefix}${quote}${newKey}${quote}`;
50
+ });
51
+ TRANSLATION_CALL_REGEX.lastIndex = 0;
52
+ if (fileReplacements === 0 || updated === source) {
53
+ continue;
54
+ }
55
+ await fs.writeFile(fullPath, updated, "utf8");
56
+ changedFiles.push(normalizePath(path.relative(rootDir, fullPath)));
57
+ }
58
+ };
59
+ await scanDirectory(rootDir);
60
+ changedFiles.sort((left, right) => left.localeCompare(right));
61
+ return {
62
+ changedFiles,
63
+ filesScanned,
64
+ replacements,
65
+ };
66
+ }
@@ -0,0 +1,46 @@
1
+ const normalizePath = (filePath) => filePath.split("\\").join("/").replace(/^\.\//, "");
2
+ const escapeRegexChar = (value) => value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
3
+ const globToRegExp = (pattern) => {
4
+ const normalized = normalizePath(pattern.trim());
5
+ let regex = "^";
6
+ for (let index = 0; index < normalized.length; index += 1) {
7
+ const char = normalized[index];
8
+ const next = normalized[index + 1];
9
+ if (char === "*" && next === "*") {
10
+ regex += ".*";
11
+ index += 1;
12
+ continue;
13
+ }
14
+ if (char === "*") {
15
+ regex += "[^/]*";
16
+ continue;
17
+ }
18
+ if (char === "?") {
19
+ regex += "[^/]";
20
+ continue;
21
+ }
22
+ regex += escapeRegexChar(char);
23
+ }
24
+ regex += "$";
25
+ return new RegExp(regex);
26
+ };
27
+ const compilePatterns = (patterns) => {
28
+ if (!patterns || patterns.length === 0) {
29
+ return [];
30
+ }
31
+ return patterns.map((pattern) => globToRegExp(pattern));
32
+ };
33
+ export const createScanMatcher = (scan) => {
34
+ const includes = compilePatterns(scan?.include);
35
+ const excludes = compilePatterns(scan?.exclude);
36
+ return (relativePath) => {
37
+ const normalized = normalizePath(relativePath);
38
+ if (includes.length > 0 && !includes.some((pattern) => pattern.test(normalized))) {
39
+ return false;
40
+ }
41
+ if (excludes.some((pattern) => pattern.test(normalized))) {
42
+ return false;
43
+ }
44
+ return true;
45
+ };
46
+ };
package/dist/server.js ADDED
@@ -0,0 +1,92 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import express from "express";
5
+ import cors from "cors";
6
+ import { readAllTranslations, writeAllTranslations } from "./fs.js";
7
+ import { buildKeyUsageMap } from "./usage.js";
8
+ import { inferUsageRoot, scanUsage } from "./usageScanner.js";
9
+ import { renameKeyUsage } from "./renameKeyUsage.js";
10
+ const resolveUiDistPath = () => {
11
+ const runtimeDir = path.dirname(fileURLToPath(import.meta.url));
12
+ const candidates = [
13
+ path.resolve(runtimeDir, "ui"),
14
+ path.resolve(process.cwd(), "packages/ui/dist"),
15
+ path.resolve(process.cwd(), "ui/dist"),
16
+ ];
17
+ for (const candidate of candidates) {
18
+ const indexPath = path.join(candidate, "index.html");
19
+ if (fs.existsSync(indexPath)) {
20
+ return candidate;
21
+ }
22
+ }
23
+ return null;
24
+ };
25
+ export function createServerApp(cfg) {
26
+ const app = express();
27
+ app.use(cors());
28
+ app.use(express.json({ limit: "5mb" }));
29
+ app.get("/api/config", (_req, res) => res.json(cfg));
30
+ app.get("/api/translations", async (_req, res) => {
31
+ const data = await readAllTranslations(cfg);
32
+ res.json(data);
33
+ });
34
+ app.get("/api/usage", async (_req, res) => {
35
+ const usage = await scanUsage(inferUsageRoot(cfg), cfg.scan);
36
+ res.json(usage);
37
+ });
38
+ app.get("/api/key-usage", async (_req, res) => {
39
+ const usage = await buildKeyUsageMap(cfg);
40
+ res.json(usage);
41
+ });
42
+ app.post("/api/translations", async (req, res) => {
43
+ const data = req.body;
44
+ await writeAllTranslations(cfg, data);
45
+ res.json({ ok: true });
46
+ });
47
+ app.post("/api/rename-key", async (req, res) => {
48
+ const body = req.body;
49
+ const oldKey = typeof body.oldKey === "string" ? body.oldKey.trim() : "";
50
+ const newKey = typeof body.newKey === "string" ? body.newKey.trim() : "";
51
+ if (!oldKey || !newKey) {
52
+ res.status(400).json({
53
+ ok: false,
54
+ error: "oldKey and newKey are required string values.",
55
+ });
56
+ return;
57
+ }
58
+ try {
59
+ const result = await renameKeyUsage(oldKey, newKey);
60
+ res.json({ ok: true, ...result });
61
+ }
62
+ catch (error) {
63
+ res.status(500).json({
64
+ ok: false,
65
+ error: error.message,
66
+ });
67
+ }
68
+ });
69
+ const uiDistPath = resolveUiDistPath();
70
+ if (uiDistPath) {
71
+ app.use(express.static(uiDistPath, { index: false }));
72
+ app.get("/", (_req, res) => {
73
+ res.sendFile(path.join(uiDistPath, "index.html"));
74
+ });
75
+ app.get(/^\/(?!api(?:\/|$)).*/, (_req, res) => {
76
+ res.sendFile(path.join(uiDistPath, "index.html"));
77
+ });
78
+ }
79
+ else {
80
+ console.warn("Gloss UI build not found. Run `npm -w @gloss/ui run build`.");
81
+ }
82
+ return app;
83
+ }
84
+ export async function startServer(cfg, port = 5179) {
85
+ const app = createServerApp(cfg);
86
+ const server = await new Promise((resolve) => {
87
+ const nextServer = app.listen(port, () => resolve(nextServer));
88
+ });
89
+ const address = server.address();
90
+ const resolvedPort = typeof address === "object" && address ? address.port : port;
91
+ return { port: resolvedPort, server };
92
+ }
@@ -0,0 +1,17 @@
1
+ const TRANSLATION_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/;
2
+ export const isLikelyTranslationKey = (value) => {
3
+ const key = value.trim();
4
+ if (key.length === 0 || key.length > 160) {
5
+ return false;
6
+ }
7
+ if (/\s/.test(key)) {
8
+ return false;
9
+ }
10
+ if (/[+;,{}()[\]\\]/.test(key)) {
11
+ return false;
12
+ }
13
+ if (key.includes("://")) {
14
+ return false;
15
+ }
16
+ return TRANSLATION_KEY_PATTERN.test(key);
17
+ };
Binary file
@@ -0,0 +1 @@
1
+ :root{--text-primary: #0f2343;--text-secondary: #3f5478;--text-muted: #5b7196;--surface-card: #ffffff;--surface-border: #d6e0f0;font-family:Manrope,Avenir Next,Segoe UI,sans-serif;line-height:1.45;font-weight:500;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}*,*:before,*:after{box-sizing:border-box}body{margin:0;min-width:320px;min-height:100vh;background:radial-gradient(circle at 8% 0%,rgba(255,186,132,.26),transparent 42%),radial-gradient(circle at 90% 18%,rgba(120,184,255,.28),transparent 40%),linear-gradient(180deg,#f3f7ff,#f8f1e8)}#root{width:100%}.gloss-app{--space-1: .5rem;--space-2: .75rem;--space-3: 1rem;--space-4: 1.5rem;--space-5: 2rem;max-width:1360px;margin:0 auto;padding:var(--space-4) var(--space-3) calc(var(--space-5) + var(--space-2));color:var(--text-primary)}.hero{padding:var(--space-3) var(--space-4);border-radius:16px;border:1px solid #d9e3f6;background:linear-gradient(140deg,#fdfeff,#f8faff 55%,#f4f8ff);box-shadow:0 10px 24px #16264612}.hero__top{display:flex;justify-content:space-between;align-items:center;gap:1rem;flex-wrap:wrap}.hero__eyebrow{margin:0;text-transform:uppercase;letter-spacing:.08em;font-size:.75rem;color:var(--text-muted)}.hero__title{margin:.4rem 0 0;font-size:clamp(2.1rem,5vw,3.5rem);line-height:1;letter-spacing:-.02em}.hero__summary{margin:var(--space-2) 0 var(--space-2);max-width:72ch;color:var(--text-secondary);line-height:1.45}.hero__stats{display:flex;flex-wrap:wrap;gap:var(--space-1);margin-bottom:var(--space-3)}.stat-chip{display:flex;align-items:baseline;gap:.45rem;padding:.4rem .68rem;border-radius:999px;background:#fffffff5;border:1px solid rgba(21,37,66,.09)}.stat-chip span{font-size:.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.06em}.stat-chip strong{font-size:1rem}.hero__actions{display:flex;flex-wrap:wrap;gap:.625rem}.language-switch{display:inline-flex;align-items:center;gap:.45rem;padding:.32rem .4rem;border-radius:999px;border:1px solid var(--surface-border);background:#fffffff0}.language-switch span{font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted);padding-inline:.25rem}.support-cards{margin-top:var(--space-2);display:grid;gap:var(--space-1);grid-template-columns:repeat(auto-fit,minmax(240px,1fr));opacity:.88}.support-card{border-radius:12px;border:1px solid #e2e9f7;background:#fbfcff;padding:var(--space-2) var(--space-3)}.support-card h2{margin:0;font-size:.95rem}.support-card p{margin:var(--space-1) 0 0;color:var(--text-secondary);font-size:.88rem;line-height:1.45}.editor-shell{margin-top:var(--space-3);border-radius:18px;border:1px solid #dbe4f5;background:#fdfefe;padding:var(--space-3);box-shadow:0 16px 30px #0f234c12;display:grid;gap:var(--space-2)}.status-bar{margin:0;border-radius:12px;border:1px solid #dbe5f7;background:#f8fbff;padding:.625rem .75rem;display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);flex-wrap:wrap}.status-bar__main{margin:0;font-weight:600;font-size:.9rem}.status-bar__main--error{color:#8f2935}.status-bar__main--warning{color:#7b5b23;display:flex;align-items:center;gap:var(--space-1);flex-wrap:wrap}.status-bar__main--success{color:#1d6a42}.status-bar__main--info{color:var(--text-secondary)}.status-bar__meta{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}.status-chip{display:inline-flex;align-items:center;gap:.35rem;border-radius:999px;border:1px solid #cad8f2;background:#f0f5ff;color:#29476f;font-size:.76rem;font-weight:600;padding:.18rem .5rem}.status-chip--muted{background:#f6f8fc;border-color:#dde4f2;color:#4d6285}.loading-state{margin:20vh auto 0;max-width:280px;border-radius:12px;border:1px solid #d5e2fb;background:#f7faff;padding:var(--space-3);text-align:center;color:var(--text-secondary)}.notice{margin:0 0 .7rem;padding:.6rem .75rem;border-radius:10px;border:1px solid transparent}.notice--error{color:#901f1a;background:#fff3f2;border-color:#f7cdc8}.notice--success{color:#0d6837;background:#edf8f0;border-color:#b6e4c3}.notice--warning{color:#7a4f08;background:#fff5de;border-color:#f4d090}.notice--stale{color:#7a2b31;background:#fff2f4;border-color:#f4c0c9;display:flex;align-items:center;gap:.6rem;flex-wrap:wrap}.editor-tabs{display:flex;gap:var(--space-1);margin:0;padding:.25rem;border:1px solid #e2e9f6;border-radius:11px;background:#f7faff}.translations-workspace{display:grid;gap:var(--space-2)}.editor-controls{display:grid;gap:var(--space-1);padding:var(--space-1);border-radius:14px;border:1px solid #e1e8f6;background:linear-gradient(180deg,#f9fbff,#f6f9ff)}.toolbar{display:flex;align-items:flex-end;justify-content:space-between;gap:var(--space-1);flex-wrap:wrap;margin:0;padding:var(--space-2);border:1px solid #e4ebf8;border-radius:12px;background:#fff}.toolbar__primary{flex:1 1 280px;min-width:240px}.toolbar__secondary{display:flex;align-items:flex-end;gap:.5rem;flex-wrap:wrap}.toolbar__field{display:grid;gap:.32rem;min-width:160px}.toolbar__field--search input{width:100%}.toolbar__field span{font-size:.75rem;font-weight:600;color:#5f7394;text-transform:uppercase;letter-spacing:.06em}.toolbar__toggle{display:flex;align-items:center;gap:.4rem;font-size:.88rem;padding:.42rem .6rem;border:1px solid #dbe5f7;border-radius:10px;background:#fdfefe}.toolbar__advanced{width:100%;border:1px solid #dbe5f7;border-radius:12px;background:#f8fbff;padding:.7rem;display:grid;gap:.6rem}.toolbar__advanced-head{display:flex;align-items:center;justify-content:space-between;gap:.6rem;flex-wrap:wrap}.toolbar__advanced-head strong{font-size:.84rem;color:#2f4b75}.toolbar__advanced-actions{display:flex;gap:.4rem;flex-wrap:wrap}.toolbar__advanced-empty{margin:0;color:var(--text-secondary);font-size:.84rem}.toolbar__rules{display:grid;gap:.45rem}.toolbar__rule{display:grid;grid-template-columns:minmax(140px,1fr) minmax(140px,1fr) minmax(140px,1fr) auto;gap:.45rem;align-items:end;padding:.55rem;border:1px solid #d9e4f8;border-radius:10px;background:#fff}.toolbar__sort{display:flex;align-items:end;gap:.45rem;flex-wrap:wrap;padding-top:.25rem;border-top:1px dashed #d4dff4}.add-key-form{display:flex;align-items:flex-end;gap:var(--space-1);flex-wrap:wrap;margin:0;padding:var(--space-2);border:1px solid #e4ebf8;border-radius:12px;background:#fff}.add-key-form input{min-width:260px;flex:1 1 280px}.add-key-form__error{margin:0;color:#8f3c45;font-size:.82rem;padding:0 .125rem}.editor-main{display:grid;grid-template-columns:minmax(230px,280px) minmax(0,1fr);gap:var(--space-3);align-items:start}.editor-content{min-width:0;border:1px solid #dfe8f6;border-radius:15px;background:#fcfdff;padding:var(--space-2);box-shadow:inset 0 1px #fff}.file-tree{border:1px solid #d8e3f4;border-radius:14px;background:linear-gradient(180deg,#f8faff,#f2f7ff);padding:var(--space-2);max-height:74vh;overflow:auto;box-shadow:inset 0 1px #fff}.file-tree__title{margin:0 0 var(--space-1);font-size:.78rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--text-muted);position:sticky;top:-.5rem;z-index:2;background:linear-gradient(180deg,#f8faff,#f8fafff5);padding:.45rem 0 .35rem}.file-tree__all-btn,.file-tree__folder-btn,.file-tree__file-btn{width:100%;text-align:left;border:1px solid transparent;border-radius:9px;background:transparent;padding:.42rem .54rem;color:var(--text-primary);font:inherit;cursor:pointer}.file-tree__all-btn,.file-tree__file-btn{display:flex;align-items:center;justify-content:space-between;gap:.5rem}.file-tree__folder-btn{display:inline-flex;align-items:center;gap:.38rem;color:#26456f;font-weight:600}.file-tree__caret{width:.8rem;color:#50688f}.file-tree__folder-name{overflow:hidden;text-overflow:ellipsis}.file-tree__all-btn:hover,.file-tree__folder-btn:hover,.file-tree__file-btn:hover{background:#e9f1ff}.file-tree__all-btn.is-selected,.file-tree__file-btn.is-selected{border-color:#8caadd;background:#e4edff;box-shadow:inset 3px 0 #466fcf}.file-tree__file-name{overflow:hidden;text-overflow:ellipsis}.file-tree__file-count{color:var(--text-muted);font-size:.72rem;font-family:JetBrains Mono,SFMono-Regular,ui-monospace,monospace;background:#edf2fd;border:1px solid #d4e0f5;border-radius:999px;padding:.1rem .38rem}.file-tree__list{list-style:none;margin:var(--space-1) 0 0;padding:0;display:grid;gap:.1rem}.file-tree__item{margin:0}.file-tree__empty{margin:.55rem 0 0;color:var(--text-secondary);font-size:.88rem}.duplicates-panel{border:1px solid var(--surface-border);border-radius:12px;background:#f8fafe;padding:.7rem}.duplicates-panel__title{margin:0 0 .65rem;font-size:.95rem;font-weight:700}.duplicates-panel__list{display:grid;gap:.75rem}.duplicate-locale{border:1px solid #d7e2f7;border-radius:10px;background:#fff;padding:.55rem}.duplicate-locale__title{margin:0 0 .45rem;font-size:.78rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--text-muted)}.duplicate-group{border:1px solid #e3ebfb;border-radius:9px;padding:.5rem;margin-top:.45rem}.duplicate-group__top{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:.5rem;align-items:center}.duplicate-group__value{overflow-wrap:anywhere}.duplicate-group__count{font-size:.78rem;color:var(--text-muted)}.duplicate-group__keys{margin:.45rem 0 0;padding-left:1.2rem;display:grid;gap:.2rem;color:var(--text-secondary);font-family:JetBrains Mono,SFMono-Regular,ui-monospace,monospace;font-size:.78rem}.table-wrap{border-radius:12px;border:1px solid #d8e3f4;background:#fff;overflow:auto;max-height:74vh}.grid-table{width:100%;min-width:1180px;border-collapse:collapse}.grid-table th{text-align:left;padding:.78rem .72rem;border-bottom:1px solid #d9e3f5;background:#f6f9ff;position:sticky;top:0;z-index:2;color:#3a557f;font-size:.76rem;text-transform:uppercase;letter-spacing:.06em}.grid-table td{padding:.72rem .66rem;border-bottom:1px solid #edf1f8;vertical-align:top}.grid-table tr.row-state--none>td{background:#fff7f6}.grid-table tr.row-state--partial>td{background:#fffaf2}.grid-table tr.row-state--none td.value-cell--dirty,.grid-table tr.row-state--partial td.value-cell--dirty{background:#e9f2ff}.grid-table tr.row-state--none td.value-cell--dirty-missing,.grid-table tr.row-state--partial td.value-cell--dirty-missing{background:#fff2df}.grid-table tbody tr:not(.usage-files-row):not(.virtual-spacer):hover>td{background-color:#f7fafe}.key-col{width:320px;min-width:320px;font-family:JetBrains Mono,SFMono-Regular,ui-monospace,monospace;font-size:.83rem;color:#1f3659;border-right:1px solid #d7e2f4;background-clip:padding-box}.key-col--unused{color:#7a5b2f}.key-col--file-selected{box-shadow:inset 3px 0 #4a74d9}.key-col--placeholder-warning{box-shadow:inset 0 -2px #e59f35}.usage-col{width:130px}.usage-cell{color:var(--text-secondary);white-space:nowrap}.usage-cell--unused{background:#fffaf3}.usage-toggle{border:0;background:none;padding:0;font:inherit;color:#1848a3;cursor:pointer;text-decoration:underline;text-underline-offset:2px}.usage-tag{font-size:.74rem;font-weight:600;color:#8b6a39;text-transform:lowercase}.usage-files-row td{background:#f8fbff;padding-top:.7rem;padding-bottom:.8rem}.virtual-spacer td{border-bottom:0;padding:0}.usage-files{font-size:.82rem;color:var(--text-secondary);border:1px solid #dde6f6;border-radius:10px;background:#fff;padding:.6rem .7rem}.usage-files strong{margin-right:.45rem;color:var(--text-primary)}.usage-files-list{margin:.45rem 0 0;padding-left:1.2rem;display:grid;gap:.26rem;color:var(--text-primary);font-family:JetBrains Mono,SFMono-Regular,ui-monospace,monospace;font-size:.78rem}.status-col{width:176px;min-width:176px;color:#4a5f80;font-size:.84rem;line-height:1.4}.status-col__tags{display:flex;flex-wrap:wrap;gap:.25rem;margin-top:.25rem}.status-inline-tag{display:inline-flex;align-items:center;border-radius:999px;padding:.1rem .42rem;font-size:.7rem;font-weight:700;letter-spacing:.03em}.status-inline-tag--warning{color:#8a5a15;background:#fff4df;border:1px solid #efd2a1}.locale-col,.locale-cell{min-width:220px}.value-cell{background:transparent}.value-cell--missing{background:#fffaf4}.value-cell--dirty{background:#e9f2ff}.value-cell--dirty-missing{background:#fff3e3}.value-cell--active{box-shadow:inset 0 0 0 2px #2c5ecf57}.value-input{box-sizing:border-box;width:100%;resize:vertical;min-height:2.25rem;line-height:1.35}.value-input--dirty{border-color:#0e5fd8;box-shadow:0 0 0 2px #0e5fd814}.actions-col{width:220px;min-width:220px}.row-actions{display:flex;gap:.4rem;flex-wrap:wrap}.rename-form{display:flex;gap:.4rem;flex-wrap:wrap;align-items:center}.inline-error{color:#9a241f;width:100%;font-size:.82rem}.empty-state{margin:1rem auto;padding:2.25rem 1.25rem;border:1px dashed #dbe4f5;border-radius:12px;background:linear-gradient(180deg,#fbfdff,#f6f9ff);text-align:center;max-width:680px;color:var(--text-secondary);line-height:1.5}.footer-actions{margin-top:var(--space-2);padding:var(--space-2) 0 0;border-top:1px solid #e2e9f6;display:flex;justify-content:flex-end;position:sticky;bottom:0;background:linear-gradient(180deg,#fdfeff00,#fdfeff 32%);z-index:3}.footer-actions .btn--primary{min-width:152px;min-height:2.5rem;font-size:.95rem}.btn{border-radius:10px;border:1px solid transparent;padding:.5rem .8rem;font-size:.9rem;font-weight:600;text-decoration:none;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;transition:transform .12s ease,box-shadow .12s ease,background .12s ease}.btn:hover{transform:translateY(-.5px)}.btn:disabled{opacity:.55;cursor:not-allowed;transform:none}.btn--primary{color:#fff;background:linear-gradient(135deg,#245fdf,#1b49be);box-shadow:0 10px 18px #1f59d942}.btn--ghost{color:#1d355c;border-color:#c2d0ea;background:#f6f9ff}.btn--support{color:#fff;background:linear-gradient(135deg,#ff7849,#f14c4c);box-shadow:0 7px 16px #f14c4c3b}.btn--danger{color:#9c212a;border-color:#eec1c7;background:#fff6f7}.btn--small{padding:.4rem .6rem;font-size:.8rem}.is-active{border-color:#4d73d7;background:#eaf1ff}input,textarea,select{border-radius:9px;border:1px solid #c4d2ea;background:#fff;color:var(--text-primary);padding:.48rem .6rem;font:inherit}input:focus,textarea:focus,select:focus{outline:none;border-color:#1f5eff;box-shadow:0 0 0 3px #1f5eff24}.modal-overlay{position:fixed;inset:0;background:#0e162480;display:flex;align-items:center;justify-content:center;padding:1rem;z-index:1000}.modal-dialog{width:min(480px,100%);border-radius:14px;border:1px solid var(--surface-border);background:var(--surface-card);box-shadow:0 24px 44px #0e1f4847;padding:1rem;display:grid;gap:.75rem}.modal-dialog__message{margin:0;color:var(--text-primary)}.modal-dialog__actions{display:flex;justify-content:flex-end;gap:.5rem}@media(max-width:860px){.gloss-app{padding:var(--space-3) var(--space-2) calc(var(--space-5) + var(--space-2))}.hero{padding:var(--space-3)}.hero__top{align-items:flex-start}.editor-shell{padding:var(--space-2)}.status-bar{align-items:flex-start}.editor-controls{padding:.5rem}.toolbar__primary,.toolbar__secondary,.toolbar__field,.toolbar__field--search,.toolbar__rule{width:100%}.toolbar__rule{grid-template-columns:1fr;align-items:stretch}.toolbar__field input,.toolbar__field select,.toolbar__secondary .btn,.add-key-form input{width:100%}.editor-main{grid-template-columns:1fr}.file-tree{max-height:none}}