@mxml3gend/gloss 0.1.0 → 0.1.2

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.
@@ -0,0 +1,113 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import path from "node:path";
4
+ import { readAllTranslations } from "./fs.js";
5
+ import { flattenObject } from "./translationTree.js";
6
+ const execFileAsync = promisify(execFile);
7
+ const projectRoot = () => process.env.INIT_CWD || process.cwd();
8
+ const normalizePath = (value) => value.split(path.sep).join("/");
9
+ const runGit = async (cwd, args) => {
10
+ const { stdout } = await execFileAsync("git", args, { cwd });
11
+ return stdout.trim();
12
+ };
13
+ const resolveLocaleFileRelativePath = (cfg, locale, cwd) => {
14
+ const directory = path.isAbsolute(cfg.path) ? cfg.path : path.resolve(cwd, cfg.path);
15
+ const filePath = path.resolve(directory, `${locale}.json`);
16
+ return normalizePath(path.relative(cwd, filePath));
17
+ };
18
+ const readJsonFromGit = async (cwd, ref, fileRelativePath) => {
19
+ if (!fileRelativePath ||
20
+ fileRelativePath.startsWith("../") ||
21
+ path.isAbsolute(fileRelativePath)) {
22
+ return {};
23
+ }
24
+ try {
25
+ const raw = await runGit(cwd, ["show", `${ref}:${fileRelativePath}`]);
26
+ return JSON.parse(raw);
27
+ }
28
+ catch {
29
+ return {};
30
+ }
31
+ };
32
+ export async function buildGitKeyDiff(cfg, baseRef = "origin/main") {
33
+ const cwd = projectRoot();
34
+ try {
35
+ const insideRepo = await runGit(cwd, ["rev-parse", "--is-inside-work-tree"]);
36
+ if (insideRepo !== "true") {
37
+ return {
38
+ available: false,
39
+ baseRef,
40
+ generatedAt: new Date().toISOString(),
41
+ keys: [],
42
+ error: "Current directory is not a git repository.",
43
+ };
44
+ }
45
+ }
46
+ catch {
47
+ return {
48
+ available: false,
49
+ baseRef,
50
+ generatedAt: new Date().toISOString(),
51
+ keys: [],
52
+ error: "Git is not available in this project.",
53
+ };
54
+ }
55
+ let resolvedBaseRef = "";
56
+ try {
57
+ resolvedBaseRef = await runGit(cwd, ["rev-parse", "--verify", `${baseRef}^{commit}`]);
58
+ }
59
+ catch {
60
+ return {
61
+ available: false,
62
+ baseRef,
63
+ generatedAt: new Date().toISOString(),
64
+ keys: [],
65
+ error: `Base ref "${baseRef}" was not found.`,
66
+ };
67
+ }
68
+ const currentTranslations = await readAllTranslations(cfg);
69
+ const currentFlatByLocale = {};
70
+ const baseFlatByLocale = {};
71
+ for (const locale of cfg.locales) {
72
+ currentFlatByLocale[locale] = flattenObject((currentTranslations[locale] ?? {}));
73
+ const localeRelativePath = resolveLocaleFileRelativePath(cfg, locale, cwd);
74
+ const baseJson = await readJsonFromGit(cwd, resolvedBaseRef, localeRelativePath);
75
+ baseFlatByLocale[locale] = flattenObject(baseJson);
76
+ }
77
+ const allKeys = Array.from(new Set(cfg.locales.flatMap((locale) => [
78
+ ...Object.keys(baseFlatByLocale[locale] ?? {}),
79
+ ...Object.keys(currentFlatByLocale[locale] ?? {}),
80
+ ]))).sort((left, right) => left.localeCompare(right));
81
+ const keys = [];
82
+ for (const key of allKeys) {
83
+ const changes = [];
84
+ for (const locale of cfg.locales) {
85
+ const before = baseFlatByLocale[locale]?.[key] ?? "";
86
+ const after = currentFlatByLocale[locale]?.[key] ?? "";
87
+ if (before === after) {
88
+ continue;
89
+ }
90
+ const beforeMissing = before.trim() === "";
91
+ const afterMissing = after.trim() === "";
92
+ const kind = beforeMissing && !afterMissing
93
+ ? "added"
94
+ : !beforeMissing && afterMissing
95
+ ? "removed"
96
+ : "changed";
97
+ changes.push({ locale, kind, before, after });
98
+ }
99
+ if (changes.length > 0) {
100
+ keys.push({
101
+ key,
102
+ changes: changes.sort((left, right) => left.locale.localeCompare(right.locale)),
103
+ });
104
+ }
105
+ }
106
+ return {
107
+ available: true,
108
+ baseRef,
109
+ resolvedBaseRef,
110
+ generatedAt: new Date().toISOString(),
111
+ keys,
112
+ };
113
+ }
package/dist/index.js CHANGED
@@ -2,8 +2,10 @@
2
2
  import fs from "node:fs/promises";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import open from "open";
5
+ import { printGlossCheck, runGlossCheck } from "./check.js";
5
6
  import { GlossConfigError, loadGlossConfig } from "./config.js";
6
7
  import { startServer } from "./server.js";
8
+ import { generateKeyTypes } from "./typegen.js";
7
9
  const DEFAULT_PORT = 5179;
8
10
  const getVersion = async () => {
9
11
  const packagePath = fileURLToPath(new URL("../package.json", import.meta.url));
@@ -16,23 +18,175 @@ const printHelp = () => {
16
18
 
17
19
  Usage:
18
20
  gloss [options]
21
+ gloss check [options]
22
+ gloss gen-types [options]
23
+ gloss open key <translation-key> [options]
19
24
 
20
25
  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})
26
+ -h, --help Show help
27
+ -v, --version Show version
28
+
29
+ Serve options:
30
+ --no-open Do not open browser automatically
31
+ -p, --port Set server port (default: ${DEFAULT_PORT})
32
+
33
+ Check options:
34
+ --format <human|json|both> Output format (default: human)
35
+ --json Shortcut for --format json
36
+
37
+ Type generation options:
38
+ --out <path> Output file for generated key types (default: i18n-keys.d.ts)
39
+
40
+ Open key options:
41
+ gloss open key <key> Open Gloss focused on a translation key
25
42
  `);
26
43
  };
27
44
  const parseArgs = (args) => {
28
- const options = {
45
+ const firstArg = args[0];
46
+ if (firstArg === "open") {
47
+ const commandArgs = args.slice(1);
48
+ const base = { help: false, version: false };
49
+ const options = {
50
+ command: "open-key",
51
+ ...base,
52
+ noOpen: false,
53
+ port: DEFAULT_PORT,
54
+ key: "",
55
+ };
56
+ if (commandArgs.length === 0) {
57
+ throw new Error("Usage: gloss open key <translation-key> [--port <number>]");
58
+ }
59
+ if (commandArgs[0] === "-h" || commandArgs[0] === "--help") {
60
+ options.help = true;
61
+ return options;
62
+ }
63
+ if (commandArgs[0] === "-v" || commandArgs[0] === "--version") {
64
+ options.version = true;
65
+ return options;
66
+ }
67
+ let index = 0;
68
+ if (commandArgs[0] === "key") {
69
+ index = 1;
70
+ }
71
+ const keyValue = commandArgs[index];
72
+ if (!keyValue || keyValue.startsWith("-")) {
73
+ throw new Error("Missing translation key. Usage: gloss open key <translation-key>");
74
+ }
75
+ options.key = keyValue.trim();
76
+ index += 1;
77
+ for (; index < commandArgs.length; index += 1) {
78
+ const arg = commandArgs[index];
79
+ if (arg === "-h" || arg === "--help") {
80
+ options.help = true;
81
+ continue;
82
+ }
83
+ if (arg === "-v" || arg === "--version") {
84
+ options.version = true;
85
+ continue;
86
+ }
87
+ if (arg === "--no-open") {
88
+ options.noOpen = true;
89
+ continue;
90
+ }
91
+ if (arg === "-p" || arg === "--port") {
92
+ const nextValue = commandArgs[index + 1];
93
+ if (!nextValue) {
94
+ throw new Error("Missing value for --port.");
95
+ }
96
+ const parsed = Number.parseInt(nextValue, 10);
97
+ if (!Number.isFinite(parsed) || parsed <= 0) {
98
+ throw new Error("Port must be a positive integer.");
99
+ }
100
+ options.port = parsed;
101
+ index += 1;
102
+ continue;
103
+ }
104
+ throw new Error(`Unknown argument for gloss open: ${arg}`);
105
+ }
106
+ return options;
107
+ }
108
+ const isCommand = firstArg && !firstArg.startsWith("-");
109
+ const command = firstArg === "check" || firstArg === "gen-types" ? firstArg : "serve";
110
+ const commandArgs = command === "serve" ? args : args.slice(1);
111
+ const base = {
29
112
  help: false,
30
113
  version: false,
114
+ };
115
+ if (command === "check") {
116
+ const options = {
117
+ command,
118
+ ...base,
119
+ format: "human",
120
+ };
121
+ for (let index = 0; index < commandArgs.length; index += 1) {
122
+ const arg = commandArgs[index];
123
+ if (arg === "-h" || arg === "--help") {
124
+ options.help = true;
125
+ continue;
126
+ }
127
+ if (arg === "-v" || arg === "--version") {
128
+ options.version = true;
129
+ continue;
130
+ }
131
+ if (arg === "--json") {
132
+ options.format = "json";
133
+ continue;
134
+ }
135
+ if (arg === "--format") {
136
+ const nextValue = commandArgs[index + 1];
137
+ if (!nextValue) {
138
+ throw new Error("Missing value for --format.");
139
+ }
140
+ if (nextValue !== "human" && nextValue !== "json" && nextValue !== "both") {
141
+ throw new Error("Invalid value for --format. Use human, json, or both.");
142
+ }
143
+ options.format = nextValue;
144
+ index += 1;
145
+ continue;
146
+ }
147
+ throw new Error(`Unknown argument for gloss check: ${arg}`);
148
+ }
149
+ return options;
150
+ }
151
+ if (command === "gen-types") {
152
+ const options = {
153
+ command,
154
+ ...base,
155
+ };
156
+ for (let index = 0; index < commandArgs.length; index += 1) {
157
+ const arg = commandArgs[index];
158
+ if (arg === "-h" || arg === "--help") {
159
+ options.help = true;
160
+ continue;
161
+ }
162
+ if (arg === "-v" || arg === "--version") {
163
+ options.version = true;
164
+ continue;
165
+ }
166
+ if (arg === "--out") {
167
+ const nextValue = commandArgs[index + 1];
168
+ if (!nextValue) {
169
+ throw new Error("Missing value for --out.");
170
+ }
171
+ options.outFile = nextValue;
172
+ index += 1;
173
+ continue;
174
+ }
175
+ throw new Error(`Unknown argument for gloss gen-types: ${arg}`);
176
+ }
177
+ return options;
178
+ }
179
+ if (isCommand && command === "serve" && firstArg !== undefined) {
180
+ throw new Error(`Unknown command: ${firstArg}`);
181
+ }
182
+ const options = {
183
+ command: "serve",
184
+ ...base,
31
185
  noOpen: false,
32
186
  port: DEFAULT_PORT,
33
187
  };
34
- for (let index = 0; index < args.length; index += 1) {
35
- const arg = args[index];
188
+ for (let index = 0; index < commandArgs.length; index += 1) {
189
+ const arg = commandArgs[index];
36
190
  if (arg === "-h" || arg === "--help") {
37
191
  options.help = true;
38
192
  continue;
@@ -46,7 +200,7 @@ const parseArgs = (args) => {
46
200
  continue;
47
201
  }
48
202
  if (arg === "-p" || arg === "--port") {
49
- const nextValue = args[index + 1];
203
+ const nextValue = commandArgs[index + 1];
50
204
  if (!nextValue) {
51
205
  throw new Error("Missing value for --port.");
52
206
  }
@@ -88,8 +242,23 @@ async function main() {
88
242
  return;
89
243
  }
90
244
  const cfg = await loadGlossConfig();
245
+ if (options.command === "check") {
246
+ const result = await runGlossCheck(cfg);
247
+ printGlossCheck(result, options.format);
248
+ if (!result.ok) {
249
+ process.exit(1);
250
+ }
251
+ return;
252
+ }
253
+ if (options.command === "gen-types") {
254
+ const result = await generateKeyTypes(cfg, { outFile: options.outFile });
255
+ console.log(`Generated ${result.keyCount} keys in ${result.outFile}`);
256
+ return;
257
+ }
91
258
  const { port } = await startServer(cfg, options.port);
92
- const url = `http://localhost:${port}`;
259
+ const url = options.command === "open-key"
260
+ ? `http://localhost:${port}/?key=${encodeURIComponent(options.key)}`
261
+ : `http://localhost:${port}`;
93
262
  console.log(`Gloss running at ${url}`);
94
263
  if (options.noOpen || process.env.CI) {
95
264
  return;
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { replaceTranslationKeyLiterals } from "./usageExtractor.js";
3
4
  const IGNORED_DIRECTORIES = new Set([
4
5
  "node_modules",
5
6
  "dist",
@@ -8,11 +9,10 @@ const IGNORED_DIRECTORIES = new Set([
8
9
  "coverage",
9
10
  ]);
10
11
  const SCANNED_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx"]);
11
- const TRANSLATION_CALL_REGEX = /(\b(?:t|translate)\s*\(\s*)(['"])([^'"]+)\2/g;
12
12
  const projectRoot = () => process.env.INIT_CWD || process.cwd();
13
13
  const normalizePath = (filePath) => filePath.split(path.sep).join("/");
14
14
  const isScannableFile = (fileName) => SCANNED_EXTENSIONS.has(path.extname(fileName));
15
- export async function renameKeyUsage(oldKey, newKey, rootDir = projectRoot()) {
15
+ export async function renameKeyUsage(oldKey, newKey, rootDir = projectRoot(), mode) {
16
16
  if (!oldKey || !newKey || oldKey === newKey) {
17
17
  return {
18
18
  changedFiles: [],
@@ -39,19 +39,11 @@ export async function renameKeyUsage(oldKey, newKey, rootDir = projectRoot()) {
39
39
  }
40
40
  filesScanned += 1;
41
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;
42
+ const { updated, replacements: fileReplacements } = replaceTranslationKeyLiterals(source, fullPath, oldKey, newKey, mode);
52
43
  if (fileReplacements === 0 || updated === source) {
53
44
  continue;
54
45
  }
46
+ replacements += fileReplacements;
55
47
  await fs.writeFile(fullPath, updated, "utf8");
56
48
  changedFiles.push(normalizePath(path.relative(rootDir, fullPath)));
57
49
  }
package/dist/server.js CHANGED
@@ -3,7 +3,9 @@ import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import express from "express";
5
5
  import cors from "cors";
6
+ import { runGlossCheck } from "./check.js";
6
7
  import { readAllTranslations, writeAllTranslations } from "./fs.js";
8
+ import { buildGitKeyDiff } from "./gitDiff.js";
7
9
  import { buildKeyUsageMap } from "./usage.js";
8
10
  import { inferUsageRoot, scanUsage } from "./usageScanner.js";
9
11
  import { renameKeyUsage } from "./renameKeyUsage.js";
@@ -39,6 +41,28 @@ export function createServerApp(cfg) {
39
41
  const usage = await buildKeyUsageMap(cfg);
40
42
  res.json(usage);
41
43
  });
44
+ app.get("/api/check", async (req, res) => {
45
+ const result = await runGlossCheck(cfg);
46
+ const summaryValue = typeof req.query.summary === "string" ? req.query.summary : "";
47
+ const summaryOnly = summaryValue === "1" || summaryValue === "true";
48
+ if (summaryOnly) {
49
+ res.json({
50
+ ok: result.ok,
51
+ generatedAt: result.generatedAt,
52
+ summary: result.summary,
53
+ hardcodedTexts: result.hardcodedTexts.slice(0, 20),
54
+ });
55
+ return;
56
+ }
57
+ res.json(result);
58
+ });
59
+ app.get("/api/git-diff", async (req, res) => {
60
+ const base = typeof req.query.base === "string" && req.query.base.trim()
61
+ ? req.query.base.trim()
62
+ : "origin/main";
63
+ const diff = await buildGitKeyDiff(cfg, base);
64
+ res.json(diff);
65
+ });
42
66
  app.post("/api/translations", async (req, res) => {
43
67
  const data = req.body;
44
68
  await writeAllTranslations(cfg, data);
@@ -56,7 +80,7 @@ export function createServerApp(cfg) {
56
80
  return;
57
81
  }
58
82
  try {
59
- const result = await renameKeyUsage(oldKey, newKey);
83
+ const result = await renameKeyUsage(oldKey, newKey, undefined, cfg.scan?.mode);
60
84
  res.json({ ok: true, ...result });
61
85
  }
62
86
  catch (error) {
@@ -1,4 +1,5 @@
1
1
  const TRANSLATION_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/;
2
+ const KEY_CHAR_PATTERN = /^[A-Za-z0-9._:/-]+$/;
2
3
  export const isLikelyTranslationKey = (value) => {
3
4
  const key = value.trim();
4
5
  if (key.length === 0 || key.length > 160) {
@@ -15,3 +16,22 @@ export const isLikelyTranslationKey = (value) => {
15
16
  }
16
17
  return TRANSLATION_KEY_PATTERN.test(key);
17
18
  };
19
+ export const getInvalidTranslationKeyReason = (value) => {
20
+ const key = value.trim();
21
+ if (!key) {
22
+ return "Key is empty.";
23
+ }
24
+ if (key.startsWith(".") || key.endsWith(".")) {
25
+ return "Key cannot start or end with a dot.";
26
+ }
27
+ if (key.includes("..")) {
28
+ return "Key cannot contain consecutive dots.";
29
+ }
30
+ if (key.split(".").some((segment) => segment.trim() === "")) {
31
+ return "Key contains an empty segment.";
32
+ }
33
+ if (!KEY_CHAR_PATTERN.test(key)) {
34
+ return "Key contains unsupported characters.";
35
+ }
36
+ return null;
37
+ };
@@ -0,0 +1,22 @@
1
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2
+ export const flattenObject = (obj) => {
3
+ const result = {};
4
+ const visit = (node, path) => {
5
+ for (const [key, value] of Object.entries(node)) {
6
+ const nextPath = path ? `${path}.${key}` : key;
7
+ if (isPlainObject(value)) {
8
+ visit(value, nextPath);
9
+ continue;
10
+ }
11
+ if (typeof value === "string") {
12
+ result[nextPath] = value;
13
+ continue;
14
+ }
15
+ if (value !== undefined) {
16
+ result[nextPath] = String(value);
17
+ }
18
+ }
19
+ };
20
+ visit(obj, "");
21
+ return result;
22
+ };
@@ -0,0 +1,30 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { readAllTranslations } from "./fs.js";
4
+ import { flattenObject } from "./translationTree.js";
5
+ const projectRoot = () => process.env.INIT_CWD || process.cwd();
6
+ const renderTypeFile = (keys) => {
7
+ const keyUnion = keys.length === 0
8
+ ? "never"
9
+ : keys.map((key) => ` | ${JSON.stringify(key)}`).join("\n");
10
+ return `/* This file is auto-generated by Gloss. Do not edit manually. */
11
+
12
+ export type I18nKey =
13
+ ${keyUnion};
14
+
15
+ export type TranslateFn = (
16
+ key: I18nKey,
17
+ variables?: Record<string, string | number>,
18
+ ) => string;
19
+
20
+ export type GlossI18nKey = I18nKey;
21
+ `;
22
+ };
23
+ export async function generateKeyTypes(cfg, options = {}) {
24
+ const data = await readAllTranslations(cfg);
25
+ const keys = Array.from(new Set(Object.values(data).flatMap((tree) => Object.keys(flattenObject((tree ?? {})))))).sort((left, right) => left.localeCompare(right));
26
+ const outFile = path.resolve(projectRoot(), options.outFile ?? "i18n-keys.d.ts");
27
+ const content = renderTypeFile(keys);
28
+ await fs.writeFile(outFile, content, "utf8");
29
+ return { outFile, keyCount: keys.length };
30
+ }
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:flex-end;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__logo{display:block;width:min(420px,100%);height:auto;margin:.4rem 0 0}.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}.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[type=button]{cursor:pointer}.status-chip--muted{background:#f6f8fc;border-color:#dde4f2;color:#4d6285}.status-chip--warning{background:#fff5de;border-color:#f4d090;color:#7a4f08}.status-chip__action{opacity:.85;font-size:.72rem;border-left:1px solid currentColor;padding-left:.4rem}.status-bar__details{width:100%;border:1px solid #e0e8f7;border-radius:10px;background:#fff;padding:.6rem .75rem}.status-bar__details strong{display:block;margin-bottom:.45rem;color:#2f4b75;font-size:.82rem}.status-bar__details p{margin:0;color:var(--text-secondary);font-size:.84rem}.status-bar__details ul{margin:0;padding-left:1rem;display:grid;gap:.3rem}.status-bar__details li{color:#35527c;font-size:.82rem;line-height:1.35}.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__git-warning{margin:0;font-size:.82rem;color:#8f3c45}.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--highlighted>td{background:#edf4ff}.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}.key-diff-block{margin-top:.7rem;padding-top:.55rem;border-top:1px dashed #d7e3f8}.key-diff-line{overflow-wrap:anywhere}.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}.status-inline-tag--info{color:#214f98;background:#edf4ff;border:1px solid #c8daf8}.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);white-space:pre-line}.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}}