@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.
@@ -11,8 +11,8 @@
11
11
  <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
12
12
  <meta name="apple-mobile-web-app-title" content="Gloss" />
13
13
  <link rel="manifest" href="/site.webmanifest" />
14
- <script type="module" crossorigin src="/assets/index-Dhb2pVPI.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-CREq9Gop.css">
14
+ <script type="module" crossorigin src="/assets/index-DfgO64nU.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-CgyZVU2h.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
Binary file
package/dist/usage.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { createScanMatcher } from "./scanFilters.js";
4
- import { isLikelyTranslationKey } from "./translationKeys.js";
4
+ import { extractTranslationKeys } from "./usageExtractor.js";
5
5
  const SUPPORTED_EXTENSIONS = [
6
6
  ".ts",
7
7
  ".tsx",
@@ -33,24 +33,6 @@ const hasSkippedPathSegment = (relativePath) => normalizePath(relativePath)
33
33
  .split("/")
34
34
  .some((segment) => SKIP_DIRECTORIES.has(segment));
35
35
  const isSupportedFile = (name) => SUPPORTED_EXTENSIONS.some((extension) => name.endsWith(extension));
36
- const extractTranslationKeys = (content) => {
37
- const keys = new Set();
38
- const regexes = [
39
- /\b(?:t|i18n\.t|translate)\(\s*["'`]([^"'`]+)["'`]\s*[\),]/g,
40
- /\bi18nKey\s*=\s*["'`]([^"'`]+)["'`]/g,
41
- ];
42
- for (const regex of regexes) {
43
- let match = regex.exec(content);
44
- while (match) {
45
- const key = match[1]?.trim();
46
- if (key && isLikelyTranslationKey(key)) {
47
- keys.add(key);
48
- }
49
- match = regex.exec(content);
50
- }
51
- }
52
- return keys;
53
- };
54
36
  const extractRelativeImports = (content) => {
55
37
  const imports = new Set();
56
38
  const importRegexes = [
@@ -120,7 +102,7 @@ const isPageFile = (relativePath) => {
120
102
  isNextAppPage ||
121
103
  isSvelteKitPage);
122
104
  };
123
- const collectFiles = async (directory, projectDir, shouldScanFile, files) => {
105
+ const collectFiles = async (directory, projectDir, shouldScanFile, cfg, files) => {
124
106
  const entries = await fs.readdir(directory, { withFileTypes: true });
125
107
  for (const entry of entries) {
126
108
  if (entry.name.startsWith(".")) {
@@ -131,7 +113,7 @@ const collectFiles = async (directory, projectDir, shouldScanFile, files) => {
131
113
  if (SKIP_DIRECTORIES.has(entry.name)) {
132
114
  continue;
133
115
  }
134
- await collectFiles(fullPath, projectDir, shouldScanFile, files);
116
+ await collectFiles(fullPath, projectDir, shouldScanFile, cfg, files);
135
117
  continue;
136
118
  }
137
119
  if (!entry.isFile() || !isSupportedFile(entry.name)) {
@@ -148,7 +130,7 @@ const collectFiles = async (directory, projectDir, shouldScanFile, files) => {
148
130
  files.push({
149
131
  filePath: fullPath,
150
132
  relativePath,
151
- keys: extractTranslationKeys(content),
133
+ keys: new Set(extractTranslationKeys(content, fullPath, cfg.scan?.mode)),
152
134
  imports: extractRelativeImports(content),
153
135
  });
154
136
  }
@@ -171,7 +153,7 @@ export async function buildKeyUsageMap(cfg) {
171
153
  if (!stat?.isDirectory()) {
172
154
  continue;
173
155
  }
174
- await collectFiles(sourceRoot, root, shouldScanFile, files);
156
+ await collectFiles(sourceRoot, root, shouldScanFile, cfg, files);
175
157
  }
176
158
  const fileByPath = new Map(files.map((file) => [path.resolve(file.filePath), file]));
177
159
  const adjacency = new Map();
@@ -0,0 +1,151 @@
1
+ import path from "node:path";
2
+ import ts from "typescript";
3
+ import { isLikelyTranslationKey } from "./translationKeys.js";
4
+ const REGEX_PATTERNS = [
5
+ /\b(?:t|i18n\.t|translate)\(\s*["'`]([^"'`]+)["'`]\s*[\),]/g,
6
+ /\bi18nKey\s*=\s*["'`]([^"'`]+)["'`]/g,
7
+ ];
8
+ const normalizeMode = (mode) => mode === "ast" ? "ast" : "regex";
9
+ const scriptKindForFile = (filePath) => {
10
+ const extension = path.extname(filePath).toLowerCase();
11
+ if (extension === ".tsx") {
12
+ return ts.ScriptKind.TSX;
13
+ }
14
+ if (extension === ".jsx") {
15
+ return ts.ScriptKind.JSX;
16
+ }
17
+ if (extension === ".ts" || extension === ".mts" || extension === ".cts") {
18
+ return ts.ScriptKind.TS;
19
+ }
20
+ return ts.ScriptKind.JS;
21
+ };
22
+ const getLiteralText = (node) => {
23
+ if (!node) {
24
+ return null;
25
+ }
26
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
27
+ return node.text;
28
+ }
29
+ return null;
30
+ };
31
+ const isTranslationCallee = (expression) => {
32
+ if (ts.isIdentifier(expression)) {
33
+ return expression.text === "t" || expression.text === "translate";
34
+ }
35
+ if (ts.isPropertyAccessExpression(expression) &&
36
+ ts.isIdentifier(expression.expression)) {
37
+ return expression.expression.text === "i18n" && expression.name.text === "t";
38
+ }
39
+ return false;
40
+ };
41
+ const isI18nKeyAttribute = (name) => ts.isIdentifier(name) && name.text === "i18nKey";
42
+ const extractWithAst = (source, filePath) => {
43
+ const keys = [];
44
+ const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, scriptKindForFile(filePath));
45
+ const pushKey = (value) => {
46
+ const key = value?.trim();
47
+ if (key && isLikelyTranslationKey(key)) {
48
+ keys.push(key);
49
+ }
50
+ };
51
+ const visit = (node) => {
52
+ if (ts.isCallExpression(node) && isTranslationCallee(node.expression)) {
53
+ pushKey(getLiteralText(node.arguments[0]));
54
+ }
55
+ else if (ts.isJsxAttribute(node) && isI18nKeyAttribute(node.name)) {
56
+ if (node.initializer && ts.isStringLiteral(node.initializer)) {
57
+ pushKey(node.initializer.text);
58
+ }
59
+ else if (node.initializer &&
60
+ ts.isJsxExpression(node.initializer) &&
61
+ node.initializer.expression) {
62
+ pushKey(getLiteralText(node.initializer.expression));
63
+ }
64
+ }
65
+ ts.forEachChild(node, visit);
66
+ };
67
+ visit(sourceFile);
68
+ return keys;
69
+ };
70
+ const extractWithRegex = (source) => {
71
+ const keys = [];
72
+ for (const regex of REGEX_PATTERNS) {
73
+ let match = regex.exec(source);
74
+ while (match) {
75
+ const key = match[1]?.trim();
76
+ if (key && isLikelyTranslationKey(key)) {
77
+ keys.push(key);
78
+ }
79
+ match = regex.exec(source);
80
+ }
81
+ regex.lastIndex = 0;
82
+ }
83
+ return keys;
84
+ };
85
+ export const extractTranslationKeys = (source, filePath, mode) => {
86
+ const normalizedMode = normalizeMode(mode);
87
+ if (normalizedMode === "ast") {
88
+ return extractWithAst(source, filePath);
89
+ }
90
+ return extractWithRegex(source);
91
+ };
92
+ export const replaceTranslationKeyLiterals = (source, filePath, oldKey, newKey, mode) => {
93
+ const normalizedMode = normalizeMode(mode);
94
+ if (normalizedMode === "regex") {
95
+ let replacements = 0;
96
+ const regex = /(\b(?:t|translate)\s*\(\s*)(['"])([^'"]+)\2/g;
97
+ const updated = source.replace(regex, (match, prefix, quote, key) => {
98
+ if (key !== oldKey) {
99
+ return match;
100
+ }
101
+ replacements += 1;
102
+ return `${prefix}${quote}${newKey}${quote}`;
103
+ });
104
+ regex.lastIndex = 0;
105
+ return { updated, replacements };
106
+ }
107
+ const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, scriptKindForFile(filePath));
108
+ const edits = [];
109
+ const queueEditForLiteral = (node) => {
110
+ const start = node.getStart(sourceFile);
111
+ const end = node.getEnd();
112
+ const quote = source[start] === "`" ? "`" : source[start] === "'" ? "'" : '"';
113
+ edits.push({ start, end, text: `${quote}${newKey}${quote}` });
114
+ };
115
+ const visit = (node) => {
116
+ if (ts.isCallExpression(node) && isTranslationCallee(node.expression)) {
117
+ const firstArg = node.arguments[0];
118
+ if (firstArg &&
119
+ (ts.isStringLiteral(firstArg) || ts.isNoSubstitutionTemplateLiteral(firstArg)) &&
120
+ firstArg.text === oldKey) {
121
+ queueEditForLiteral(firstArg);
122
+ }
123
+ }
124
+ else if (ts.isJsxAttribute(node) && isI18nKeyAttribute(node.name)) {
125
+ if (node.initializer && ts.isStringLiteral(node.initializer)) {
126
+ if (node.initializer.text === oldKey) {
127
+ queueEditForLiteral(node.initializer);
128
+ }
129
+ }
130
+ else if (node.initializer &&
131
+ ts.isJsxExpression(node.initializer) &&
132
+ node.initializer.expression &&
133
+ (ts.isStringLiteral(node.initializer.expression) ||
134
+ ts.isNoSubstitutionTemplateLiteral(node.initializer.expression)) &&
135
+ node.initializer.expression.text === oldKey) {
136
+ queueEditForLiteral(node.initializer.expression);
137
+ }
138
+ }
139
+ ts.forEachChild(node, visit);
140
+ };
141
+ visit(sourceFile);
142
+ edits.sort((left, right) => right.start - left.start);
143
+ let updated = source;
144
+ for (const edit of edits) {
145
+ updated = `${updated.slice(0, edit.start)}${edit.text}${updated.slice(edit.end)}`;
146
+ }
147
+ return {
148
+ updated,
149
+ replacements: edits.length,
150
+ };
151
+ };
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { createScanMatcher } from "./scanFilters.js";
4
- import { isLikelyTranslationKey } from "./translationKeys.js";
4
+ import { extractTranslationKeys } from "./usageExtractor.js";
5
5
  const IGNORED_DIRECTORIES = new Set([
6
6
  "node_modules",
7
7
  "dist",
@@ -15,10 +15,6 @@ const IGNORED_DIRECTORIES = new Set([
15
15
  "storybook-static",
16
16
  ]);
17
17
  const SCANNED_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx"]);
18
- const USAGE_REGEXES = [
19
- /\b(?:t|i18n\.t|translate)\(\s*["'`]([^"'`]+)["'`]\s*[\),]/g,
20
- /\bi18nKey\s*=\s*["'`]([^"'`]+)["'`]/g,
21
- ];
22
18
  const projectRoot = () => process.env.INIT_CWD || process.cwd();
23
19
  const normalizePath = (filePath) => filePath.split(path.sep).join("/");
24
20
  const isScannableFile = (fileName) => SCANNED_EXTENSIONS.has(path.extname(fileName));
@@ -72,25 +68,18 @@ export async function scanUsage(rootDir = projectRoot(), scan) {
72
68
  continue;
73
69
  }
74
70
  const source = await fs.readFile(fullPath, "utf8");
75
- for (const usageRegex of USAGE_REGEXES) {
76
- let match = usageRegex.exec(source);
77
- while (match) {
78
- const key = match[1]?.trim();
79
- if (key && isLikelyTranslationKey(key)) {
80
- if (!usage[key]) {
81
- usage[key] = { count: 0, files: [] };
82
- seenFilesByKey.set(key, new Set());
83
- }
84
- usage[key].count += 1;
85
- const fileSet = seenFilesByKey.get(key);
86
- if (fileSet && !fileSet.has(relativePath)) {
87
- fileSet.add(relativePath);
88
- usage[key].files.push(relativePath);
89
- }
90
- }
91
- match = usageRegex.exec(source);
71
+ const keys = extractTranslationKeys(source, fullPath, scan?.mode);
72
+ for (const key of keys) {
73
+ if (!usage[key]) {
74
+ usage[key] = { count: 0, files: [] };
75
+ seenFilesByKey.set(key, new Set());
76
+ }
77
+ usage[key].count += 1;
78
+ const fileSet = seenFilesByKey.get(key);
79
+ if (fileSet && !fileSet.has(relativePath)) {
80
+ fileSet.add(relativePath);
81
+ usage[key].files.push(relativePath);
92
82
  }
93
- usageRegex.lastIndex = 0;
94
83
  }
95
84
  }
96
85
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mxml3gend/gloss",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Local-first CLI + web app for managing i18n translation files",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -26,6 +26,7 @@
26
26
  "dev": "tsx src/index.ts",
27
27
  "dev:server": "tsx src/devServer.ts",
28
28
  "build": "tsc -p tsconfig.json && node ./scripts/copy-ui-dist.mjs",
29
+ "prepublishOnly": "npm run build",
29
30
  "test": "npm run build && node --test tests/**/*.test.mjs"
30
31
  },
31
32
  "devDependencies": {
@@ -38,6 +39,15 @@
38
39
  "dependencies": {
39
40
  "cors": "^2.8.6",
40
41
  "express": "^5.2.1",
41
- "open": "^11.0.0"
42
- }
42
+ "open": "^11.0.0",
43
+ "typescript": "^5.9.3"
44
+ },
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/MXML3GEND/gloss.git"
48
+ },
49
+ "bugs": {
50
+ "url": "https://github.com/MXML3GEND/gloss/issues"
51
+ },
52
+ "homepage": "https://github.com/MXML3GEND/gloss#readme"
43
53
  }
@@ -1 +0,0 @@
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}}