@appaflytech/zeyrek-ai-mcp 0.0.9 → 0.0.11
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/README.md +11 -6
- package/dist/knowledge/designSnapshot.js +77 -0
- package/dist/knowledge/normalize.js +47 -0
- package/dist/knowledge/outlines.js +24 -7
- package/dist/knowledge/pageDiscover.js +151 -0
- package/dist/knowledge/themeDiscover.js +884 -0
- package/dist/knowledge/themeResolve.js +4 -2
- package/dist/resources/examples/design-snapshot.json +157 -0
- package/dist/resources/guides/theme.md +84 -1
- package/dist/tools/flows.js +119 -17
- package/dist/tools/pages.js +16 -0
- package/dist/tools/themes.js +52 -0
- package/package.json +2 -2
- package/dist/tools/globalFunctions.js +0 -63
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
import { GOOGLE_FONTS, MAX_PRESET_VERSIONS, PALETTE_GROUPS, RADIUS_SCALE_DEFAULTS, SPACING_SCALE_DEFAULTS, TOKENS, TYPE_SCALE_DEFAULTS, isValidPresetName } from './themeResolve.js';
|
|
2
|
+
import { SNAPSHOT_HINT_IGNORE } from './designSnapshot.js';
|
|
3
|
+
import { componentKnowledge } from './componentCatalog.js';
|
|
4
|
+
import { PlatformApiError } from '../platform/errors.js';
|
|
5
|
+
import { secretKeyPattern } from '../platform/secrets.js';
|
|
6
|
+
const RATIO_TOLERANCE = 0.05;
|
|
7
|
+
const FULL_ROUND_PX = 999;
|
|
8
|
+
const FAMILY_ALIASES = { gray: 'grey' };
|
|
9
|
+
const RAMP_PREFIX_WORDS = ['color', 'colors', 'colour', 'colours', 'palette'];
|
|
10
|
+
const TEXT_WORDS = ['text', 'foreground', 'fg', 'font', 'fonts'];
|
|
11
|
+
const BACKGROUND_WORDS = ['background', 'backgrounds', 'bg'];
|
|
12
|
+
const BORDER_WORDS = ['border', 'borders', 'stroke', 'strokes', 'divider', 'outline'];
|
|
13
|
+
const STATUS_WORDS = {
|
|
14
|
+
success: 'success', positive: 'success',
|
|
15
|
+
warning: 'warning', caution: 'warning',
|
|
16
|
+
error: 'error', danger: 'error', destructive: 'error', negative: 'error',
|
|
17
|
+
info: 'info'
|
|
18
|
+
};
|
|
19
|
+
const ACCENT_WORDS = ['primary', 'brand', 'accent', 'hover', 'muted', 'subtle', 'tint'];
|
|
20
|
+
/** Grubun VARSAYILAN anahtarını gösteren kelimeler; bunlar niteleyici sayılmaz. */
|
|
21
|
+
const BASE_WORDS = ['primary', 'default', 'base', 'main'];
|
|
22
|
+
/** Grup → [anahtar, o anahtarı seçen kelimeler] öncelik sırasıyla; ilk eşleşen kazanır. */
|
|
23
|
+
const GROUP_KEYS = {
|
|
24
|
+
text: [['secondary', ['secondary', 'muted', 'subdued']], ['disabled', ['disabled']]],
|
|
25
|
+
background: [
|
|
26
|
+
['inverse', ['inverse']],
|
|
27
|
+
['overlay', ['overlay', 'backdrop', 'scrim']],
|
|
28
|
+
['readonly', ['readonly']],
|
|
29
|
+
['disabled', ['disabled']],
|
|
30
|
+
['surfaceAlt', ['alt', 'secondary', 'subtle', 'muted']],
|
|
31
|
+
['surface', ['surface', 'card']],
|
|
32
|
+
['canvas', ['canvas', 'page', 'app', 'body']]
|
|
33
|
+
],
|
|
34
|
+
border: [['secondary', ['secondary', 'strong']]],
|
|
35
|
+
accent: [['hover', ['hover']], ['muted', ['muted', 'soft']], ['subtle', ['subtle', 'tint', 'light']]]
|
|
36
|
+
};
|
|
37
|
+
const GROUP_DEFAULT_KEY = { text: 'primary', background: 'canvas', border: 'primary', accent: 'primary' };
|
|
38
|
+
/** Grubu seçen kelimeler (niteleyici sayılmaz). `surface`/`card`/`overlay`… hem grup hem anahtar kelimesidir. */
|
|
39
|
+
const GROUP_WORDS = {
|
|
40
|
+
text: TEXT_WORDS,
|
|
41
|
+
background: [...BACKGROUND_WORDS, 'canvas', 'page', 'surface', 'card', 'overlay', 'backdrop', 'scrim'],
|
|
42
|
+
border: BORDER_WORDS,
|
|
43
|
+
status: ['status', 'state', ...Object.keys(STATUS_WORDS)],
|
|
44
|
+
accent: ACCENT_WORDS
|
|
45
|
+
};
|
|
46
|
+
const TYPOGRAPHY_WORDS = ['font', 'family', 'typography', 'weight'];
|
|
47
|
+
const SPACING_WORDS = ['spacing', 'space', 'gap', 'padding', 'inset'];
|
|
48
|
+
const RADIUS_WORDS = ['radius', 'rounded', 'corner', 'round'];
|
|
49
|
+
const LEVEL_WORDS = {
|
|
50
|
+
xs: 'xs', sm: 'sm', small: 'sm', md: 'md', medium: 'md', base: 'md', default: 'md',
|
|
51
|
+
lg: 'lg', large: 'lg', xl: 'xl', pill: 'pill', full: 'pill'
|
|
52
|
+
};
|
|
53
|
+
const ROLE_WORDS = {
|
|
54
|
+
h1: 'h1', display: 'h1', title: 'h1',
|
|
55
|
+
h2: 'h2', h3: 'h3', h4: 'h4',
|
|
56
|
+
label: 'label', overline: 'label',
|
|
57
|
+
body: 'p', p: 'p', paragraph: 'p', base: 'p', regular: 'p',
|
|
58
|
+
small: 'small', caption: 'small', helper: 'small', footnote: 'small'
|
|
59
|
+
};
|
|
60
|
+
const SHADOW_PICK_WORDS = ['md', 'medium', 'default', 'card'];
|
|
61
|
+
/** Referans kademe önceliği — `md` yokken taban bu kademenin varsayılan oranından türetilir (B-9). */
|
|
62
|
+
const REFERENCE_LEVELS = ['sm', 'lg', 'xs', 'xl'];
|
|
63
|
+
/**
|
|
64
|
+
* Koyu palet türetme oranları (g-06): accent açık tonlara, muted/subtle koyu zemine çekilir;
|
|
65
|
+
* status renkleri hafif açılır. text/background/border türetilmez — platformun DARK_PALETTE'i
|
|
66
|
+
* zaten koyu moda göre ayarlıdır.
|
|
67
|
+
*/
|
|
68
|
+
const DERIVE_DARK = {
|
|
69
|
+
'accent.primary': { toward: 255, ratio: 0.35 },
|
|
70
|
+
'accent.hover': { toward: 255, ratio: 0.45 },
|
|
71
|
+
'accent.muted': { toward: 0, ratio: 0.55 },
|
|
72
|
+
'accent.subtle': { toward: 0, ratio: 0.7 },
|
|
73
|
+
'status.info': { toward: 255, ratio: 0.2 },
|
|
74
|
+
'status.success': { toward: 255, ratio: 0.2 },
|
|
75
|
+
'status.warning': { toward: 255, ratio: 0.2 },
|
|
76
|
+
'status.error': { toward: 255, ratio: 0.2 }
|
|
77
|
+
};
|
|
78
|
+
const isPlainObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
79
|
+
const words = (name) => name.toLowerCase().split(/[/\-_.\s]+/).filter(word => word !== '');
|
|
80
|
+
const has = (list, candidates) => list.some(word => candidates.includes(word));
|
|
81
|
+
const round2 = (value) => Math.round(value * 100) / 100;
|
|
82
|
+
const nearDefault = (ratio, expected) => Math.abs(ratio - expected) <= RATIO_TOLERANCE;
|
|
83
|
+
const kebab = (key) => key.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
|
|
84
|
+
const isTone = (word) => /^\d{2,3}$/.test(word);
|
|
85
|
+
/** Adı kuraldan sapan değişkenler: `accent.onPrimary` runtime'da `--ng-text-on-accent`tir (f-08). */
|
|
86
|
+
const PALETTE_VAR_ALIASES = { 'accent.onPrimary': '--ng-text-on-accent' };
|
|
87
|
+
/** Palet yolu → runtime değişkeni (`buildThemeStyle`: `--ng-background-surface-alt`). */
|
|
88
|
+
const paletteVar = (group, key) => PALETTE_VAR_ALIASES[`${group}.${key}`] ?? `--ng-${group}-${kebab(key)}`;
|
|
89
|
+
/* ---- Değer ayrıştırma ---- */
|
|
90
|
+
/**
|
|
91
|
+
* Renk → `#RRGGBB` (varsayılan) ya da `#RRGGBBAA` (`keepAlpha`, yalnız `background.overlay`:
|
|
92
|
+
* platformun kendi varsayılanı `#364b764d`, alfa orada VERİDİR — inceleme B-4).
|
|
93
|
+
*/
|
|
94
|
+
export function normalizeHex(value, options = {}) {
|
|
95
|
+
const text = value.trim();
|
|
96
|
+
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(text);
|
|
97
|
+
if (short)
|
|
98
|
+
return { hex: `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`.toUpperCase() };
|
|
99
|
+
const full = /^#([0-9a-f]{6})$/i.exec(text);
|
|
100
|
+
if (full)
|
|
101
|
+
return { hex: `#${full[1]}`.toUpperCase() };
|
|
102
|
+
const withAlpha = /^#([0-9a-f]{6})([0-9a-f]{2})$/i.exec(text);
|
|
103
|
+
if (withAlpha) {
|
|
104
|
+
return options.keepAlpha
|
|
105
|
+
? { hex: `#${withAlpha[1]}${withAlpha[2]}`.toUpperCase() }
|
|
106
|
+
: { hex: `#${withAlpha[1]}`.toUpperCase(), note: 'alfa kanalı düşürüldü — tema renkleri #RRGGBB (yalnız background.overlay alfa taşır)' };
|
|
107
|
+
}
|
|
108
|
+
const rgb = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*([\d.]+)\s*)?\)$/i.exec(text);
|
|
109
|
+
if (rgb) {
|
|
110
|
+
const channel = (part) => Math.min(255, Number(part)).toString(16).padStart(2, '0');
|
|
111
|
+
const alpha = rgb[4] === undefined ? undefined : Number(rgb[4]);
|
|
112
|
+
const base = `#${channel(rgb[1])}${channel(rgb[2])}${channel(rgb[3])}`.toUpperCase();
|
|
113
|
+
if (alpha !== undefined && alpha < 1) {
|
|
114
|
+
return options.keepAlpha
|
|
115
|
+
? { hex: `${base}${Math.round(alpha * 255).toString(16).padStart(2, '0').toUpperCase()}`, note: `rgba → #RRGGBBAA (alfa ${alpha})` }
|
|
116
|
+
: { hex: base, note: `rgba → hex, alfa ${alpha} düşürüldü` };
|
|
117
|
+
}
|
|
118
|
+
return { hex: base, note: 'rgb → hex' };
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
/** `#RRGGBB`'yi beyaza (255) ya da siyaha (0) doğru `ratio` kadar karıştırır; kanal başına yuvarlar. */
|
|
123
|
+
export function mixHex(hex, toward, ratio) {
|
|
124
|
+
const channels = [1, 3, 5].map(offset => Number.parseInt(hex.slice(offset, offset + 2), 16));
|
|
125
|
+
const mixed = channels.map(channel => Math.round(channel + (toward - channel) * ratio));
|
|
126
|
+
return `#${mixed.map(channel => channel.toString(16).padStart(2, '0')).join('')}`.toUpperCase();
|
|
127
|
+
}
|
|
128
|
+
const hasAlpha = (value) => /^#[0-9a-f]{8}$/i.test(value.trim()) || /^rgba?\([^)]*,\s*(0?\.\d+|0)\s*\)$/i.test(value.trim());
|
|
129
|
+
const looksLikeColor = (value) => typeof value === 'string' && /^\s*(#|rgba?\(|hsla?\()/i.test(value);
|
|
130
|
+
function parseLength(value) {
|
|
131
|
+
if (typeof value === 'number')
|
|
132
|
+
return Number.isFinite(value) ? value : undefined;
|
|
133
|
+
const match = /^\s*(-?\d+(?:\.\d+)?)\s*(?:px)?\s*$/.exec(value);
|
|
134
|
+
return match ? Number(match[1]) : undefined;
|
|
135
|
+
}
|
|
136
|
+
function median(values) {
|
|
137
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
138
|
+
return sorted[Math.floor(sorted.length / 2)];
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Rol tespiti iki adımdır: önce grup (text · background · border · status · accent), sonra anahtar.
|
|
142
|
+
* Anahtarı seçen bir kelime yoksa VARSAYILAN anahtara yalnız başka niteleyici de yoksa düşülür;
|
|
143
|
+
* `bg/error` ya da `text/on-primary` gibi tanınmayan niteleyici `unknown` döner (B-3).
|
|
144
|
+
*/
|
|
145
|
+
function colorRole(list) {
|
|
146
|
+
// `text/on-primary`, `on-accent`, `brand/on`: accent ÜSTÜNDEKİ metin — palette.accent.onPrimary (f-08).
|
|
147
|
+
if (has(list, ['on']) && has(list, ['primary', 'accent', 'brand'])) {
|
|
148
|
+
const unknown = list.filter(word => !['on', 'primary', 'accent', 'brand', ...TEXT_WORDS].includes(word) && !isTone(word));
|
|
149
|
+
return unknown.length > 0 ? { group: 'accent', unknown } : { group: 'accent', key: 'onPrimary' };
|
|
150
|
+
}
|
|
151
|
+
const group = has(list, TEXT_WORDS) ? 'text'
|
|
152
|
+
: has(list, GROUP_WORDS.background) ? 'background'
|
|
153
|
+
: has(list, BORDER_WORDS) ? 'border'
|
|
154
|
+
: list.some(word => STATUS_WORDS[word] !== undefined) ? 'status'
|
|
155
|
+
: has(list, ACCENT_WORDS) ? 'accent'
|
|
156
|
+
: undefined;
|
|
157
|
+
if (group === undefined)
|
|
158
|
+
return undefined;
|
|
159
|
+
const tone = list.find(isTone);
|
|
160
|
+
let key;
|
|
161
|
+
let keyWords = [];
|
|
162
|
+
if (group === 'status') {
|
|
163
|
+
const status = list.find(word => STATUS_WORDS[word] !== undefined);
|
|
164
|
+
key = STATUS_WORDS[status];
|
|
165
|
+
keyWords = [status];
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
const match = GROUP_KEYS[group].find(([, candidates]) => has(list, candidates));
|
|
169
|
+
if (match) {
|
|
170
|
+
key = match[0];
|
|
171
|
+
keyWords = GROUP_KEYS[group].flatMap(([, candidates]) => candidates);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const known = new Set([...GROUP_WORDS[group], ...keyWords, ...BASE_WORDS]);
|
|
175
|
+
const unknown = list.filter(word => !known.has(word) && !isTone(word));
|
|
176
|
+
if (unknown.length > 0)
|
|
177
|
+
return { group, unknown };
|
|
178
|
+
return { group, key: key ?? GROUP_DEFAULT_KEY[group], ...(tone === undefined ? {} : { tone }) };
|
|
179
|
+
}
|
|
180
|
+
const rampFamilies = () => TOKENS.colors;
|
|
181
|
+
function rampKey(family, tone) {
|
|
182
|
+
const canonical = FAMILY_ALIASES[family] ?? family;
|
|
183
|
+
const tones = rampFamilies()[canonical];
|
|
184
|
+
if (tones === undefined)
|
|
185
|
+
return undefined;
|
|
186
|
+
const key = Object.keys(tones).find(candidate => candidate === tone || candidate.replace(/\D/g, '') === tone);
|
|
187
|
+
if (key === undefined) {
|
|
188
|
+
return { family: canonical, error: `ton '${tone}' tanınmadı — '${canonical}' rampasının tonları: ${Object.keys(tones).map(k => k.replace(/\D/g, '')).join(', ')}` };
|
|
189
|
+
}
|
|
190
|
+
return { family: canonical, key };
|
|
191
|
+
}
|
|
192
|
+
/** `colors/blue/500` · `color-gray-200` → [aile, ton]; öneksiz `blue/500` de aynı. */
|
|
193
|
+
function rampWords(list) {
|
|
194
|
+
const stripped = list.filter((word, index) => !(index === 0 && RAMP_PREFIX_WORDS.includes(word)));
|
|
195
|
+
return stripped.length === 2 && isTone(stripped[1]) ? [stripped[0], stripped[1]] : undefined;
|
|
196
|
+
}
|
|
197
|
+
/* ---- hints ---- */
|
|
198
|
+
const HINT_FORMS = '<grup>.<anahtar> (iki kümeye de) | palette.<grup>.<anahtar> | paletteDark.<grup>.<anahtar> | colors.<aile>.<ton> | ignore';
|
|
199
|
+
function parseHint(hint) {
|
|
200
|
+
if (hint === SNAPSHOT_HINT_IGNORE)
|
|
201
|
+
return { kind: 'ignore' };
|
|
202
|
+
const segments = hint.split('.').filter(segment => segment !== '');
|
|
203
|
+
const paletteKey = (group, key) => (PALETTE_GROUPS[group] ?? []).includes(key);
|
|
204
|
+
if (segments.length === 2 && paletteKey(segments[0], segments[1]))
|
|
205
|
+
return { kind: 'palette', group: segments[0], key: segments[1] };
|
|
206
|
+
if (segments.length === 3) {
|
|
207
|
+
const [root, group, key] = segments;
|
|
208
|
+
if ((root === 'palette' || root === 'paletteDark') && paletteKey(group, key))
|
|
209
|
+
return { kind: 'palette', group, key, root };
|
|
210
|
+
if (root === 'colors') {
|
|
211
|
+
const resolved = rampKey(group, key);
|
|
212
|
+
if (resolved !== undefined && 'key' in resolved)
|
|
213
|
+
return { kind: 'ramp', family: resolved.family, key: resolved.key };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
throw new PlatformApiError(`hints: '${hint}' tanınmadı — geçerli biçimler: ${HINT_FORMS}. Palet grupları: ${Object.keys(PALETTE_GROUPS).join(', ')}; rampalar: ${Object.keys(rampFamilies()).join(', ')}.`, 400);
|
|
217
|
+
}
|
|
218
|
+
/** Dış API (testler ve rehber): çıplak `<grup>.<anahtar>` aydınlık palete çözülür. */
|
|
219
|
+
export function resolveHintPath(hint) {
|
|
220
|
+
const parsed = parseHint(hint);
|
|
221
|
+
if (parsed.kind === 'ignore')
|
|
222
|
+
return { kind: 'ignore' };
|
|
223
|
+
if (parsed.kind === 'ramp')
|
|
224
|
+
return { kind: 'path', path: `colors.${parsed.family}.${parsed.key}` };
|
|
225
|
+
return { kind: 'path', path: `${parsed.root ?? 'palette'}.${parsed.group}.${parsed.key}` };
|
|
226
|
+
}
|
|
227
|
+
/* ---- Öneri kurucu ---- */
|
|
228
|
+
class ThemeDraft {
|
|
229
|
+
record = {};
|
|
230
|
+
owners = new Map();
|
|
231
|
+
mapping = [];
|
|
232
|
+
unmapped = [];
|
|
233
|
+
normalized = [];
|
|
234
|
+
/** Aydınlık palete YAZILAN hex → `--ng-*` adı; preset gövdeleri buna bağlanır (K6). */
|
|
235
|
+
paletteVars = new Map();
|
|
236
|
+
set(path, value) {
|
|
237
|
+
const segments = path.split('.');
|
|
238
|
+
let cursor = this.record;
|
|
239
|
+
for (const segment of segments.slice(0, -1)) {
|
|
240
|
+
const next = cursor[segment];
|
|
241
|
+
if (!isPlainObject(next))
|
|
242
|
+
cursor[segment] = {};
|
|
243
|
+
cursor = cursor[segment];
|
|
244
|
+
}
|
|
245
|
+
cursor[segments[segments.length - 1]] = value;
|
|
246
|
+
}
|
|
247
|
+
get(path) {
|
|
248
|
+
let cursor = this.record;
|
|
249
|
+
for (const segment of path.split('.')) {
|
|
250
|
+
if (!isPlainObject(cursor))
|
|
251
|
+
return undefined;
|
|
252
|
+
cursor = cursor[segment];
|
|
253
|
+
}
|
|
254
|
+
return cursor;
|
|
255
|
+
}
|
|
256
|
+
claim(path, source) {
|
|
257
|
+
const owner = this.owners.get(path);
|
|
258
|
+
if (owner !== undefined)
|
|
259
|
+
return owner;
|
|
260
|
+
this.owners.set(path, source);
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
map(entry) {
|
|
264
|
+
this.mapping.push(entry);
|
|
265
|
+
}
|
|
266
|
+
drop(source, value, reason) {
|
|
267
|
+
this.unmapped.push({ source, value, reason });
|
|
268
|
+
}
|
|
269
|
+
color(source, raw, path, via, note) {
|
|
270
|
+
if (typeof raw !== 'string') {
|
|
271
|
+
this.drop(source, raw, 'renk değeri metin değil');
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const parsed = normalizeHex(raw, { keepAlpha: path.endsWith('.background.overlay') });
|
|
275
|
+
if (parsed === undefined) {
|
|
276
|
+
this.drop(source, raw, `hex değil (THEME-005): '${raw}' — '#RRGGBB' bekleniyor; hsl/ad/alias çevrilmez`);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const owner = this.claim(path, source);
|
|
280
|
+
if (owner !== undefined) {
|
|
281
|
+
this.drop(source, raw, `çakışma — '${owner}' bu yolu aldı (${path}); hints ile ayırın (${HINT_FORMS})`);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (parsed.hex !== raw.trim() || parsed.note !== undefined) {
|
|
285
|
+
this.normalized.push(`${source}: '${raw}' → '${parsed.hex}'${parsed.note === undefined ? '' : ` (${parsed.note})`}`);
|
|
286
|
+
}
|
|
287
|
+
this.set(path, parsed.hex);
|
|
288
|
+
this.map({ source, value: raw, path, via, ...(note === undefined ? {} : { note }) });
|
|
289
|
+
const [root, group, key] = path.split('.');
|
|
290
|
+
if (root === 'palette' && group !== undefined && key !== undefined)
|
|
291
|
+
this.paletteVars.set(parsed.hex, paletteVar(group, key));
|
|
292
|
+
}
|
|
293
|
+
theme() {
|
|
294
|
+
for (const key of Object.keys(this.record)) {
|
|
295
|
+
const value = this.record[key];
|
|
296
|
+
if (isPlainObject(value) && Object.keys(value).length === 0)
|
|
297
|
+
delete this.record[key];
|
|
298
|
+
}
|
|
299
|
+
return this.record;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* `get_variable_defs` tipografi değişkenini metin olarak verir:
|
|
304
|
+
* `Font(family: "Inter", style: Semi Bold, size: 12, weight: 600, lineHeight: 100, letterSpacing: 5)`.
|
|
305
|
+
* Aile, punto ve ağırlık okunur; rol adın kelimelerinden gelir (ölçüldü 2026-09-16, RFQ kiti).
|
|
306
|
+
*/
|
|
307
|
+
export function parseFontValue(value) {
|
|
308
|
+
if (typeof value !== 'string' || !/^\s*Font\(/.test(value))
|
|
309
|
+
return undefined;
|
|
310
|
+
const family = /family:\s*"([^"]+)"/.exec(value)?.[1];
|
|
311
|
+
if (family === undefined)
|
|
312
|
+
return undefined;
|
|
313
|
+
const size = /\bsize:\s*(\d+(?:\.\d+)?)/.exec(value)?.[1];
|
|
314
|
+
const weight = /\bweight:\s*(\d{3})/.exec(value)?.[1];
|
|
315
|
+
return { family, ...(size === undefined ? {} : { size: Number(size) }), ...(weight === undefined ? {} : { weight: Number(weight) }) };
|
|
316
|
+
}
|
|
317
|
+
function classify(name, value) {
|
|
318
|
+
if (looksLikeColor(value))
|
|
319
|
+
return 'color';
|
|
320
|
+
if (parseFontValue(value) !== undefined)
|
|
321
|
+
return 'typography';
|
|
322
|
+
const list = words(name);
|
|
323
|
+
if (has(list, TYPOGRAPHY_WORDS) || (has(list, ['size']) && has(list, ['font', 'text', 'type'])))
|
|
324
|
+
return 'typography';
|
|
325
|
+
if (has(list, SPACING_WORDS))
|
|
326
|
+
return 'spacing';
|
|
327
|
+
if (has(list, RADIUS_WORDS))
|
|
328
|
+
return 'radius';
|
|
329
|
+
return 'other';
|
|
330
|
+
}
|
|
331
|
+
function mapColorByName(draft, source, raw, root) {
|
|
332
|
+
const allWords = words(source);
|
|
333
|
+
// Figma koleksiyonları modu ad önekiyle taşır (`Light/Basic/Primary`): önek kümeyle uyuşuyorsa
|
|
334
|
+
// düşer, uyuşmuyorsa değişken yanlış kümededir — tahmin edilmez, yönlendirilir.
|
|
335
|
+
const prefixMode = allWords[0] === 'light' ? 'palette' : allWords[0] === 'dark' ? 'paletteDark' : undefined;
|
|
336
|
+
if (prefixMode !== undefined && prefixMode !== root) {
|
|
337
|
+
draft.drop(source, raw, prefixMode === 'paletteDark'
|
|
338
|
+
? "koyu mod değişkeni ('Dark/…') aydınlık kümede — variablesDark'a taşıyın"
|
|
339
|
+
: "aydınlık mod değişkeni ('Light/…') koyu kümede — variables'a taşıyın");
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const list = prefixMode === undefined ? allWords : allWords.slice(1);
|
|
343
|
+
const role = colorRole(list);
|
|
344
|
+
if (role !== undefined && 'key' in role) {
|
|
345
|
+
const note = role.tone === undefined
|
|
346
|
+
? undefined
|
|
347
|
+
: `ton '${role.tone}' düştü — rol kelimesi kazandı; rampa için rolsüz ad kullanın ('${Object.keys(rampFamilies()).slice(0, 3).join('/<ton>, ')}/<ton> …')`;
|
|
348
|
+
draft.color(source, raw, `${root}.${role.group}.${role.key}`, 'role', note);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (role !== undefined) {
|
|
352
|
+
const keys = role.group === 'status' ? Object.values(STATUS_WORDS) : [GROUP_DEFAULT_KEY[role.group], ...(GROUP_KEYS[role.group] ?? []).map(([key]) => key)];
|
|
353
|
+
draft.drop(source, raw, `'${role.group}' grubunda '${role.unknown.join('/')}' için anahtar yok — grubun anahtarları: ${[...new Set(keys)].join(' · ')}; varsayılana düşülmez, kararı hints ile verin`);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const ramp = rampWords(list);
|
|
357
|
+
if (ramp !== undefined) {
|
|
358
|
+
const resolved = rampKey(ramp[0], ramp[1]);
|
|
359
|
+
if (resolved !== undefined && 'error' in resolved) {
|
|
360
|
+
draft.drop(source, raw, resolved.error);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (resolved !== undefined) {
|
|
364
|
+
if (root === 'paletteDark') {
|
|
365
|
+
draft.drop(source, raw, 'rampa moda göre ayrılmaz — colors tek kümedir; koyu kümede yalnız rol renkleri okunur');
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
draft.color(source, raw, `colors.${resolved.family}.${resolved.key}`, 'ramp');
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
draft.drop(source, raw, `rol tanınmadı — ad şu kelimelerden birini taşımalı: ${[...TEXT_WORDS, ...BACKGROUND_WORDS, ...BORDER_WORDS, ...Object.keys(STATUS_WORDS), ...ACCENT_WORDS].join(' · ')} ya da '<aile>/<ton>' (${Object.keys(rampFamilies()).join(', ')}); kararınızı hints ile verin`);
|
|
373
|
+
}
|
|
374
|
+
function levelOf(list) {
|
|
375
|
+
return list.map(word => LEVEL_WORDS[word]).find(level => level !== undefined);
|
|
376
|
+
}
|
|
377
|
+
function mapLengths(draft, entries, kind, gaps) {
|
|
378
|
+
const basePath = kind === 'spacing' ? 'spacing' : 'borderRadius';
|
|
379
|
+
const scalePath = kind === 'spacing' ? 'spacingScale' : 'radiusScale';
|
|
380
|
+
const defaults = kind === 'spacing' ? SPACING_SCALE_DEFAULTS : RADIUS_SCALE_DEFAULTS;
|
|
381
|
+
const levelNames = kind === 'spacing' ? 'xs · sm · md · lg · xl' : 'sm · md · lg · pill';
|
|
382
|
+
if (entries.length === 0) {
|
|
383
|
+
gaps.add(basePath);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
// Köşede tam yuvarlak (≥ 999 px) kademesi ne olursa olsun pill'dir; boşlukta pill kademesi yoktur (B-13).
|
|
387
|
+
const pills = [];
|
|
388
|
+
const scaled = [];
|
|
389
|
+
for (const entry of entries) {
|
|
390
|
+
const isPill = kind === 'radius' && (entry.level === 'pill' || (entry.value >= FULL_ROUND_PX && entry.level !== 'sm' && entry.level !== 'md' && entry.level !== 'lg'));
|
|
391
|
+
if (isPill)
|
|
392
|
+
pills.push(entry);
|
|
393
|
+
else if (entry.level === 'pill')
|
|
394
|
+
draft.drop(entry.source, entry.raw, `kademe 'pill' ${kind === 'spacing' ? 'spacing' : 'köşe'}'te yok — ${levelNames}`);
|
|
395
|
+
else
|
|
396
|
+
scaled.push(entry);
|
|
397
|
+
}
|
|
398
|
+
for (const entry of pills) {
|
|
399
|
+
if (entry.value >= FULL_ROUND_PX) {
|
|
400
|
+
draft.map({ source: entry.source, value: entry.raw, path: 'radiusScale.pill', via: 'variable', note: `tam yuvarlak (≥ ${FULL_ROUND_PX}px) platform varsayılanıdır, yazılmadı` });
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
draft.set('radiusScale.pill', `${entry.value}px`);
|
|
404
|
+
draft.map({ source: entry.source, value: entry.raw, path: 'radiusScale.pill', via: 'variable' });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (scaled.length === 0) {
|
|
408
|
+
gaps.add(basePath);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const platformBase = parseLength(kind === 'spacing' ? TOKENS.spacing : TOKENS.borderRadius);
|
|
412
|
+
const md = scaled.find(entry => entry.level === 'md');
|
|
413
|
+
const leveled = scaled.filter(entry => entry.level !== undefined && defaults[entry.level] !== undefined);
|
|
414
|
+
const reference = md ?? REFERENCE_LEVELS.map(level => leveled.find(entry => entry.level === level)).find(entry => entry !== undefined);
|
|
415
|
+
const unleveled = scaled.filter(entry => entry.level === undefined);
|
|
416
|
+
let base;
|
|
417
|
+
let baseSource;
|
|
418
|
+
let baseNote;
|
|
419
|
+
if (md !== undefined) {
|
|
420
|
+
base = md.value;
|
|
421
|
+
baseSource = md;
|
|
422
|
+
}
|
|
423
|
+
else if (reference !== undefined) {
|
|
424
|
+
// md yokken taban kademeli girdiden TÜRETİLİR: referans kademenin oranı varsayılan sayılır (B-9).
|
|
425
|
+
const expected = defaults[reference.level];
|
|
426
|
+
base = round2(reference.value / expected);
|
|
427
|
+
baseSource = reference;
|
|
428
|
+
baseNote = `referans kademe '${reference.level}' — taban ${reference.value} / ${expected} = ${base}px olarak türetildi, bu kademenin oranı varsayılan sayıldı`;
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
base = median(unleveled.map(entry => entry.value));
|
|
432
|
+
baseSource = unleveled.find(entry => entry.value === base);
|
|
433
|
+
baseNote = `kademe adı yok — taban ${unleveled.length} değerin medyanından (${base}px)`;
|
|
434
|
+
}
|
|
435
|
+
if (base <= 0) {
|
|
436
|
+
for (const entry of scaled)
|
|
437
|
+
draft.drop(entry.source, entry.raw, `taban ${base} — 0 ya da negatif uzunluktan oran hesaplanamaz (THEME-006/007)`);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (base !== platformBase)
|
|
441
|
+
draft.set(basePath, `${base}px`);
|
|
442
|
+
draft.map({
|
|
443
|
+
source: baseSource.source,
|
|
444
|
+
value: baseSource.raw,
|
|
445
|
+
path: md !== undefined || reference === undefined ? basePath : `${scalePath}.${baseSource.level}`,
|
|
446
|
+
via: 'variable',
|
|
447
|
+
note: baseNote ?? (base === platformBase ? `platform varsayılanına (${platformBase}px) eşit, yazılmadı` : undefined)
|
|
448
|
+
});
|
|
449
|
+
for (const entry of scaled) {
|
|
450
|
+
if (entry === baseSource)
|
|
451
|
+
continue;
|
|
452
|
+
if (entry.level === undefined) {
|
|
453
|
+
if (md === undefined && reference === undefined) {
|
|
454
|
+
draft.map({ source: entry.source, value: entry.raw, path: basePath, via: 'variable', note: 'kademe adı yok — yalnız taban medyanına katıldı' });
|
|
455
|
+
}
|
|
456
|
+
else {
|
|
457
|
+
draft.drop(entry.source, entry.raw, `kademe adı yok — ${levelNames}; taban '${baseSource.source}' oldu`);
|
|
458
|
+
}
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
if (entry.level === 'md') {
|
|
462
|
+
draft.drop(entry.source, entry.raw, `ikinci 'md' kademesi — taban '${baseSource.source}' oldu`);
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
const expected = defaults[entry.level];
|
|
466
|
+
if (expected === undefined) {
|
|
467
|
+
draft.drop(entry.source, entry.raw, `kademe '${entry.level}' temada yok — ${levelNames}`);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
if (entry.value <= 0) {
|
|
471
|
+
draft.drop(entry.source, entry.raw, `${entry.value} — 0 ya da negatif kademe oran üretmez (THEME-007)`);
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
const ratio = round2(entry.value / base);
|
|
475
|
+
const path = `${scalePath}.${entry.level}`;
|
|
476
|
+
if (nearDefault(ratio, expected)) {
|
|
477
|
+
draft.map({ source: entry.source, value: entry.raw, path, via: 'variable', note: `oran ${ratio} varsayılana (${expected}) ±${RATIO_TOLERANCE} içinde, yazılmadı — yalnız sapan yazılır` });
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
draft.set(path, ratio);
|
|
481
|
+
draft.map({ source: entry.source, value: entry.raw, path, via: 'variable' });
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
function googleFont(name) {
|
|
485
|
+
const lower = name.trim().toLowerCase();
|
|
486
|
+
return GOOGLE_FONTS.find(font => font.toLowerCase() === lower);
|
|
487
|
+
}
|
|
488
|
+
const cssWeight = (value) => {
|
|
489
|
+
const text = String(value).trim().toLowerCase();
|
|
490
|
+
return /^[1-9]00$/.test(text) || text === 'normal' || text === 'bold' ? text : undefined;
|
|
491
|
+
};
|
|
492
|
+
function mapTypography(draft, records, gaps) {
|
|
493
|
+
const body = records.find(record => record.role === 'p');
|
|
494
|
+
const heading = records.find(record => record.role === 'h1');
|
|
495
|
+
const platformSize = parseLength(TOKENS.fontSize);
|
|
496
|
+
const families = [];
|
|
497
|
+
if (body?.family !== undefined)
|
|
498
|
+
families.push({ field: body.family, path: 'fontFamily' });
|
|
499
|
+
else
|
|
500
|
+
gaps.add('fontFamily');
|
|
501
|
+
if (heading?.family !== undefined && String(heading.family.value).trim().toLowerCase() !== String(body?.family?.value ?? '').trim().toLowerCase()) {
|
|
502
|
+
families.push({ field: heading.family, path: 'headingFontFamily' });
|
|
503
|
+
}
|
|
504
|
+
for (const { field, path } of families) {
|
|
505
|
+
const via = typeof field.raw === 'number' ? 'textStyle' : field.source === String(field.raw) ? 'textStyle' : 'variable';
|
|
506
|
+
const requested = String(field.value);
|
|
507
|
+
const listed = googleFont(requested);
|
|
508
|
+
if (listed === undefined) {
|
|
509
|
+
gaps.add(path);
|
|
510
|
+
draft.drop(field.source, field.raw, `'${requested}' panelin Google Fonts listesinde yok (THEME-008) — runtime fontu ADIYLA Google'dan çeker, tanınmayan ad sessizce sans-serif'e düşer. Listedeki bir adı anlık görüntüde düzeltin ya da alanı boş bırakın`);
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
if (listed !== requested.trim())
|
|
514
|
+
draft.normalized.push(`${field.source}: ${path} '${requested}' → '${listed}' (listedeki yazım)`);
|
|
515
|
+
if (path === 'fontFamily' && listed === TOKENS.fontSource) {
|
|
516
|
+
draft.map({ source: field.source, value: field.raw, path, via, note: `platform varsayılanı '${TOKENS.fontSource}', yazılmadı` });
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
draft.set(path, listed);
|
|
520
|
+
draft.map({ source: field.source, value: field.raw, path, via });
|
|
521
|
+
}
|
|
522
|
+
const sized = records.filter(record => record.size !== undefined);
|
|
523
|
+
if (sized.length === 0) {
|
|
524
|
+
gaps.add('typeScale');
|
|
525
|
+
}
|
|
526
|
+
else if (body?.size === undefined) {
|
|
527
|
+
gaps.add('typeScale');
|
|
528
|
+
for (const record of sized)
|
|
529
|
+
draft.drop(record.size.source, record.size.raw, "gövde (p) boyutu yok — oranlar tabana göre hesaplanır; 'p' rolünde bir metin stili ya da font/size/body değişkeni gerekir");
|
|
530
|
+
}
|
|
531
|
+
else if (body.size.value <= 0) {
|
|
532
|
+
for (const record of sized)
|
|
533
|
+
draft.drop(record.size.source, record.size.raw, `gövde boyutu ${body.size.value} — 0 ya da negatif tabandan oran hesaplanamaz (THEME-006)`);
|
|
534
|
+
}
|
|
535
|
+
else {
|
|
536
|
+
const base = body.size.value;
|
|
537
|
+
if (base !== platformSize)
|
|
538
|
+
draft.set('fontSize', `${base}px`);
|
|
539
|
+
draft.map({ source: body.size.source, value: body.size.raw, path: 'fontSize', via: 'textStyle', note: base === platformSize ? `platform varsayılanına (${platformSize}px) eşit, yazılmadı` : undefined });
|
|
540
|
+
// Platformda p tabanın 0,925'idir; Figma gövdesi tabanın KENDİSİ olduğu için p oranı 1 yazılır,
|
|
541
|
+
// yoksa 15 px gövde 13,9 px çizilir. Sapan kuralı burada da geçerli: 1 ≠ 0,925.
|
|
542
|
+
for (const record of sized) {
|
|
543
|
+
const level = record.role;
|
|
544
|
+
const size = record.size;
|
|
545
|
+
if (size.value <= 0) {
|
|
546
|
+
draft.drop(size.source, size.raw, `${size.value} — 0 ya da negatif punto oran üretmez`);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
const ratio = level === 'p' ? 1 : round2(size.value / base);
|
|
550
|
+
const expected = TYPE_SCALE_DEFAULTS[level];
|
|
551
|
+
const path = `typeScale.${level}`;
|
|
552
|
+
if (nearDefault(ratio, expected)) {
|
|
553
|
+
draft.map({ source: size.source, value: size.raw, path, via: 'textStyle', note: `oran ${ratio} varsayılana (${expected}) ±${RATIO_TOLERANCE} içinde, yazılmadı` });
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
draft.set(path, ratio);
|
|
557
|
+
draft.map({ source: size.source, value: size.raw, path, via: 'textStyle', ...(level === 'p' ? { note: 'p oranı 1: Figma gövde boyutu tabanın kendisi (platformda p = 0,925 × taban)' } : {}) });
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (body?.weight !== undefined) {
|
|
561
|
+
const weight = cssWeight(body.weight.value);
|
|
562
|
+
if (weight === undefined) {
|
|
563
|
+
draft.drop(body.weight.source, body.weight.raw, `ağırlık '${String(body.weight.value)}' CSS değeri değil — 100–900 sayı ya da normal/bold; Figma stil adını ('Regular', 'Semi Bold') sayıya çevirip anlık görüntüye yazın`);
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
if (weight !== TOKENS.fontWeight)
|
|
567
|
+
draft.set('fontWeight', weight);
|
|
568
|
+
draft.map({ source: body.weight.source, value: body.weight.raw, path: 'fontWeight', via: 'textStyle', note: weight === TOKENS.fontWeight ? `platform varsayılanına (${TOKENS.fontWeight}) eşit, yazılmadı` : undefined });
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
function mapShadow(draft, snapshot, gaps) {
|
|
573
|
+
const effects = snapshot.effects ?? [];
|
|
574
|
+
if (effects.length === 0) {
|
|
575
|
+
gaps.add('shadow');
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
const byBlur = [...effects].sort((a, b) => a.blur - b.blur);
|
|
579
|
+
const chosen = effects.find(effect => has(words(effect.name), SHADOW_PICK_WORDS)) ?? byBlur[Math.floor(byBlur.length / 2)];
|
|
580
|
+
for (const effect of effects) {
|
|
581
|
+
if (effect !== chosen)
|
|
582
|
+
draft.drop(effect.name, effect.blur, `tema tek gölge tarifi taşır (sm/md/lg ondan türetilir); '${chosen.name}' seçildi`);
|
|
583
|
+
}
|
|
584
|
+
const parsed = normalizeHex(chosen.color);
|
|
585
|
+
if (parsed === undefined) {
|
|
586
|
+
draft.drop(chosen.name, chosen.color, `gölge rengi hex değil (THEME-004): '${chosen.color}' — altı haneli hex olmalı, yoksa üç gölge birden görünmez olur`);
|
|
587
|
+
gaps.add('shadow');
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const opacity = chosen.opacity <= 1 ? Math.round(chosen.opacity * 100) : Math.round(chosen.opacity);
|
|
591
|
+
const shadow = { offsetX: chosen.offsetX, offsetY: chosen.offsetY, blur: chosen.blur, spread: chosen.spread, color: parsed.hex, opacity };
|
|
592
|
+
const same = JSON.stringify(shadow) === JSON.stringify(TOKENS.shadow);
|
|
593
|
+
if (parsed.hex !== chosen.color.trim() || parsed.note !== undefined) {
|
|
594
|
+
draft.normalized.push(`${chosen.name}: shadow.color '${chosen.color}' → '${parsed.hex}'${parsed.note === undefined ? '' : ` (${parsed.note}; gölge saydamlığı 'opacity' alanıdır)`}`);
|
|
595
|
+
}
|
|
596
|
+
if (chosen.opacity <= 1)
|
|
597
|
+
draft.normalized.push(`${chosen.name}: shadow.opacity ${chosen.opacity} → ${opacity} (tema 0–100 yüzde ister)`);
|
|
598
|
+
if (!same)
|
|
599
|
+
draft.set('shadow', shadow);
|
|
600
|
+
draft.map({ source: chosen.name, value: chosen.blur, path: 'shadow', via: 'effect', note: same ? 'platform varsayılanına eşit, yazılmadı' : undefined });
|
|
601
|
+
}
|
|
602
|
+
function mapComponents(draft, snapshot) {
|
|
603
|
+
/** Tip → verilmiş versiyon sayısı; `button/primary` V1, `button/secondary` V2 … (g-05). */
|
|
604
|
+
const versionsByType = new Map();
|
|
605
|
+
for (const [name, spec] of Object.entries(snapshot.components ?? {})) {
|
|
606
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
607
|
+
if (!isValidPresetName(slug)) {
|
|
608
|
+
draft.drop(name, JSON.stringify(spec), `preset adı geçersiz ('${slug}') — 1–40 karakter, '$' ile başlamaz`);
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
// Yanıt gövdesinde anahtar adı gizli-anahtar desenine uyarsa `maskSecrets` değeri '***' yapar ve
|
|
612
|
+
// ajan onu theme_update'e kopyalar (B-8; themes.ts'teki `tokens` vakasıyla aynı tuzak).
|
|
613
|
+
if (secretKeyPattern.test(slug)) {
|
|
614
|
+
draft.drop(name, JSON.stringify(spec), `preset adı '${slug}' gateway'in gizli-anahtar maskesine takılıyor (${secretKeyPattern.source}) — yanıtta '***' olurdu; bileşeni anlık görüntüde yeniden adlandırın`);
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
const owner = draft.claim(`presets.${slug}`, name);
|
|
618
|
+
if (owner !== undefined) {
|
|
619
|
+
draft.drop(name, JSON.stringify(spec), `çakışma — '${owner}' da '${slug}' slug'ına iniyor; bileşeni anlık görüntüde yeniden adlandırın`);
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
const body = {};
|
|
623
|
+
const px = (value) => `${value}px`;
|
|
624
|
+
const colorProp = (prop, raw) => {
|
|
625
|
+
if (raw === undefined)
|
|
626
|
+
return;
|
|
627
|
+
// Değer OLDUĞU GİBİ yazılır (CSS rgba/alfa çizer — B-6); yalnız alfasız düz renk eşlenmiş bir
|
|
628
|
+
// palet değerine denk geliyorsa var(--ng-*)'a bağlanır ki preset temayı izlesin (K6).
|
|
629
|
+
const parsed = hasAlpha(raw) ? undefined : normalizeHex(raw);
|
|
630
|
+
const variable = parsed === undefined ? undefined : draft.paletteVars.get(parsed.hex);
|
|
631
|
+
if (variable !== undefined) {
|
|
632
|
+
body[prop] = `var(${variable})`;
|
|
633
|
+
draft.map({ source: `${name}.${prop}`, value: raw, path: `presets.${slug}.${prop}`, via: 'component→var', note: `${parsed.hex} eşlenmiş bir palet değeri — preset temayı izlesin diye ${variable}'a bağlandı` });
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
body[prop] = raw;
|
|
637
|
+
};
|
|
638
|
+
colorProp('background', spec.background);
|
|
639
|
+
// Zemini accent.primary olan bileşenin düz metin rengi "accent üstündeki metin"dir (f-08):
|
|
640
|
+
// palette.accent.onPrimary önerilir; aynı değer sessiz, farklı değer çakışma. Önce önerilir ki
|
|
641
|
+
// aşağıdaki colorProp('color') aynı hex'i var(--ng-text-on-accent)'e bağlasın.
|
|
642
|
+
if (body.background === `var(${paletteVar('accent', 'primary')})` && spec.color !== undefined && !hasAlpha(spec.color)) {
|
|
643
|
+
const parsedColor = normalizeHex(spec.color);
|
|
644
|
+
if (parsedColor !== undefined && draft.get('palette.accent.onPrimary') !== parsedColor.hex) {
|
|
645
|
+
draft.color(`${name}.color`, spec.color, 'palette.accent.onPrimary', 'component', `'${name}' zemini accent.primary — metin rengi accent üstündeki metin (--ng-text-on-accent) olarak önerildi; verilmezse platform kontrasttan türetir`);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
colorProp('color', spec.color);
|
|
649
|
+
colorProp('border', spec.border);
|
|
650
|
+
if (spec.paddingX !== undefined || spec.paddingY !== undefined)
|
|
651
|
+
body.padding = `${px(spec.paddingY ?? 0)} ${px(spec.paddingX ?? 0)}`;
|
|
652
|
+
if (spec.borderRadius !== undefined)
|
|
653
|
+
body.borderRadius = typeof spec.borderRadius === 'number' ? px(spec.borderRadius) : spec.borderRadius;
|
|
654
|
+
if (spec.height !== undefined)
|
|
655
|
+
body.minHeight = px(spec.height);
|
|
656
|
+
if (spec.fontSize !== undefined)
|
|
657
|
+
body.fontSize = px(spec.fontSize);
|
|
658
|
+
if (spec.fontWeight !== undefined)
|
|
659
|
+
body.fontWeight = String(spec.fontWeight);
|
|
660
|
+
if (spec.shadow !== undefined)
|
|
661
|
+
body.boxShadow = spec.shadow;
|
|
662
|
+
if (Object.keys(body).length === 0) {
|
|
663
|
+
draft.drop(name, JSON.stringify(spec), 'ölçü yok — boş preset yazılmaz');
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
draft.set(`presets.${slug}`, body);
|
|
667
|
+
// Bileşen preseti (g-05): adın ilk kelimesi katalogdaki bir TİP ise o tipin yeni düğümleri bu
|
|
668
|
+
// ölçülerle doğsun. Tip tahmini yok ('input' ≠ 'textinput'); gövde `props.style` zarfındadır
|
|
669
|
+
// (N-2 ile aynı biçim), prop adı eşlemesi (height → size) yapılmaz.
|
|
670
|
+
const type = words(name)[0] ?? '';
|
|
671
|
+
if (!(type in componentKnowledge)) {
|
|
672
|
+
draft.map({ source: name, value: JSON.stringify(spec), path: `presets.${slug}`, via: 'component', note: `'${type}' katalog tipi değil, componentPresets üretilmedi — bileşen preseti istiyorsanız adı katalog tipiyle başlatın (component_list)` });
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
draft.map({ source: name, value: JSON.stringify(spec), path: `presets.${slug}`, via: 'component' });
|
|
676
|
+
const count = (versionsByType.get(type) ?? 0) + 1;
|
|
677
|
+
if (count > MAX_PRESET_VERSIONS) {
|
|
678
|
+
draft.drop(name, JSON.stringify(spec), `'${type}' tipi ${MAX_PRESET_VERSIONS} versiyonla doldu (V1…V${MAX_PRESET_VERSIONS}, THEME-002) — bileşen preseti yazılmadı, adlı preset '${slug}' yazıldı`);
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
versionsByType.set(type, count);
|
|
682
|
+
const version = `V${count}`;
|
|
683
|
+
if (count === 1)
|
|
684
|
+
draft.set(`componentPresets.${type}.defaultVersion`, 'V1');
|
|
685
|
+
draft.set(`componentPresets.${type}.versions.${version}`, { props: { style: { ...body } } });
|
|
686
|
+
draft.map({ source: name, value: JSON.stringify(spec), path: `componentPresets.${type}.versions.${version}`, via: 'component', note: count === 1 ? `'${type}' tipinden bırakılan yeni düğümler bu stille başlar (defaultVersion V1); mevcut düğümler için adlı preset '${slug}'` : undefined });
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
/** Koyu palet türetme (g-06): yalnız bayrakla, yalnız aydınlıkta YAZILAN accent/status anahtarları. */
|
|
690
|
+
function deriveDarkPalette(draft, snapshot) {
|
|
691
|
+
if (Object.keys(snapshot.variablesDark ?? {}).length > 0) {
|
|
692
|
+
draft.normalized.push('deriveDark yok sayıldı — anlık görüntüde variablesDark var; gerçek koyu küme türetmeden üstündür');
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
for (const entry of [...draft.mapping]) {
|
|
696
|
+
const match = /^palette\.((?:accent|status)\.[a-zA-Z]+)$/.exec(entry.path);
|
|
697
|
+
if (match === null)
|
|
698
|
+
continue;
|
|
699
|
+
const rule = DERIVE_DARK[match[1]];
|
|
700
|
+
const light = draft.get(entry.path);
|
|
701
|
+
if (rule === undefined || typeof light !== 'string')
|
|
702
|
+
continue;
|
|
703
|
+
const path = `paletteDark.${match[1]}`;
|
|
704
|
+
if (draft.claim(path, `${entry.source} (derived)`) !== undefined)
|
|
705
|
+
continue;
|
|
706
|
+
const derived = mixHex(light, rule.toward, rule.ratio);
|
|
707
|
+
draft.set(path, derived);
|
|
708
|
+
draft.map({
|
|
709
|
+
source: entry.source,
|
|
710
|
+
value: light,
|
|
711
|
+
path,
|
|
712
|
+
via: 'derived',
|
|
713
|
+
note: `türetildi — Figma'da koyu küme yok; ${light} ${rule.toward === 255 ? 'beyazla' : 'siyahla'} %${Math.round(rule.ratio * 100)} karıştırıldı. Marka kararı değil başlangıç noktası; koyu ekranı page_screenshot(colorScheme:'dark') ile görüp değeri değiştirin`
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
/** Tipografi kaynaklarını role göre birleştirir; aynı alanın ikinci FARKLI değeri çakışmadır (B-5). */
|
|
718
|
+
class TypographyPool {
|
|
719
|
+
draft;
|
|
720
|
+
records = new Map();
|
|
721
|
+
constructor(draft) {
|
|
722
|
+
this.draft = draft;
|
|
723
|
+
}
|
|
724
|
+
add(role, source, raw, fields) {
|
|
725
|
+
if (role === undefined) {
|
|
726
|
+
this.draft.drop(source, raw, 'rol tanınmadı — role verin (h1 · h2 · h3 · h4 · label · p · small) ya da ad rol kelimesi taşısın (body, h1, caption …)');
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
const current = this.records.get(role);
|
|
730
|
+
if (current === undefined) {
|
|
731
|
+
this.records.set(role, { role, ...fields });
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
const conflicts = [];
|
|
735
|
+
for (const key of ['family', 'size', 'weight']) {
|
|
736
|
+
const incoming = fields[key];
|
|
737
|
+
const existing = current[key];
|
|
738
|
+
if (incoming === undefined || existing === undefined)
|
|
739
|
+
continue;
|
|
740
|
+
if (String(incoming.value).trim().toLowerCase() === String(existing.value).trim().toLowerCase())
|
|
741
|
+
continue;
|
|
742
|
+
conflicts.push(`${key}: '${existing.source}' ${role} seviyesini aldı (${String(existing.value)} ≠ ${String(incoming.value)})`);
|
|
743
|
+
}
|
|
744
|
+
if (conflicts.length > 0) {
|
|
745
|
+
this.draft.drop(source, raw, `çakışma — ${conflicts.join('; ')}; fazlasını anlık görüntüden çıkarın ya da role verin`);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
for (const key of ['family', 'size', 'weight']) {
|
|
749
|
+
const incoming = fields[key];
|
|
750
|
+
if (incoming !== undefined && current[key] === undefined)
|
|
751
|
+
current[key] = incoming;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
export function discoverTheme(snapshot, options = {}) {
|
|
756
|
+
const draft = new ThemeDraft();
|
|
757
|
+
const gaps = new Set();
|
|
758
|
+
const hints = new Map();
|
|
759
|
+
const known = new Set([...Object.keys(snapshot.variables), ...Object.keys(snapshot.variablesDark ?? {})]);
|
|
760
|
+
const unknownHints = Object.keys(snapshot.hints ?? {}).filter(name => !known.has(name));
|
|
761
|
+
// Anlık görüntüde olmayan bir ada verilen hint sessizce ölmesin: Figma'da ad değişince karar da
|
|
762
|
+
// değişmelidir (B-12). Hints yalnız `variables` / `variablesDark` adlarına uygulanır.
|
|
763
|
+
if (unknownHints.length > 0) {
|
|
764
|
+
throw new PlatformApiError(`hints: ${unknownHints.map(name => `'${name}'`).join(', ')} anlık görüntünün variables/variablesDark kümesinde yok — Figma'da ad değiştiyse hints'i de güncelleyin.`, 400);
|
|
765
|
+
}
|
|
766
|
+
for (const [source, hint] of Object.entries(snapshot.hints ?? {}))
|
|
767
|
+
hints.set(source, parseHint(hint));
|
|
768
|
+
const spacing = [];
|
|
769
|
+
const radius = [];
|
|
770
|
+
const typography = new TypographyPool(draft);
|
|
771
|
+
/** Hint kümeyi bilir: çıplak yol o kümenin paletine, açık kök yalnız kendi kümesine (B-1). */
|
|
772
|
+
const consumeHint = (source, raw, set) => {
|
|
773
|
+
const hint = hints.get(source);
|
|
774
|
+
if (hint === undefined)
|
|
775
|
+
return false;
|
|
776
|
+
if (hint.kind === 'ignore') {
|
|
777
|
+
draft.drop(source, raw, `hints: ${SNAPSHOT_HINT_IGNORE}`);
|
|
778
|
+
}
|
|
779
|
+
else if (hint.kind === 'ramp') {
|
|
780
|
+
if (set === 'paletteDark')
|
|
781
|
+
draft.drop(source, raw, 'rampa moda göre ayrılmaz — colors tek kümedir; hint aydınlık kümede uygulandı');
|
|
782
|
+
else
|
|
783
|
+
draft.color(source, raw, `colors.${hint.family}.${hint.key}`, 'hint');
|
|
784
|
+
}
|
|
785
|
+
else if (hint.root !== undefined && hint.root !== set) {
|
|
786
|
+
draft.drop(source, raw, `hint '${hint.root}.${hint.group}.${hint.key}' bu küme (${set === 'palette' ? 'variables' : 'variablesDark'}) için değil — iki kümeye birden çıplak '${hint.group}.${hint.key}' yazın`);
|
|
787
|
+
}
|
|
788
|
+
else {
|
|
789
|
+
draft.color(source, raw, `${set}.${hint.group}.${hint.key}`, 'hint');
|
|
790
|
+
}
|
|
791
|
+
return true;
|
|
792
|
+
};
|
|
793
|
+
// 1. geçiş: hints — açık karar, rol tablosundan önce yolu alır.
|
|
794
|
+
for (const [source, raw] of Object.entries(snapshot.variables))
|
|
795
|
+
consumeHint(source, raw, 'palette');
|
|
796
|
+
for (const [source, raw] of Object.entries(snapshot.variablesDark ?? {}))
|
|
797
|
+
consumeHint(source, raw, 'paletteDark');
|
|
798
|
+
// 2. geçiş: aydınlık küme, türüne göre.
|
|
799
|
+
for (const [source, raw] of Object.entries(snapshot.variables)) {
|
|
800
|
+
if (hints.has(source))
|
|
801
|
+
continue;
|
|
802
|
+
const kind = classify(source, raw);
|
|
803
|
+
const list = words(source);
|
|
804
|
+
if (kind === 'color') {
|
|
805
|
+
mapColorByName(draft, source, raw, 'palette');
|
|
806
|
+
}
|
|
807
|
+
else if (kind === 'spacing' || kind === 'radius') {
|
|
808
|
+
const value = parseLength(raw);
|
|
809
|
+
if (value === undefined) {
|
|
810
|
+
draft.drop(source, raw, `uzunluk değil: '${raw}' — sayı ya da 'NNpx' bekleniyor`);
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
;
|
|
814
|
+
(kind === 'spacing' ? spacing : radius).push({ source, raw, value, level: levelOf(list) });
|
|
815
|
+
}
|
|
816
|
+
else if (kind === 'typography') {
|
|
817
|
+
const role = list.map(word => ROLE_WORDS[word] ?? (word === 'heading' ? 'h1' : undefined)).find(level => level !== undefined);
|
|
818
|
+
const field = { value: raw, source, raw };
|
|
819
|
+
const font = parseFontValue(raw);
|
|
820
|
+
if (font !== undefined) {
|
|
821
|
+
typography.add(role, source, raw, {
|
|
822
|
+
family: { ...field, value: font.family },
|
|
823
|
+
...(font.size === undefined ? {} : { size: { ...field, value: font.size } }),
|
|
824
|
+
...(font.weight === undefined ? {} : { weight: { ...field, value: font.weight } })
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
else if (has(list, ['family']))
|
|
828
|
+
typography.add(role, source, raw, { family: { ...field, value: String(raw) } });
|
|
829
|
+
else if (has(list, ['weight']))
|
|
830
|
+
typography.add(role, source, raw, { weight: field });
|
|
831
|
+
else if (has(list, ['size'])) {
|
|
832
|
+
const size = parseLength(raw);
|
|
833
|
+
if (size === undefined) {
|
|
834
|
+
draft.drop(source, raw, `punto değil: '${raw}' — sayı ya da 'NNpx' bekleniyor`);
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
typography.add(role, source, raw, { size: { ...field, value: size } });
|
|
838
|
+
}
|
|
839
|
+
else {
|
|
840
|
+
draft.drop(source, raw, "tipografi değişkeni tanınmadı — ad 'family', 'size' ya da 'weight' taşımalı (font/size/h1, font/family/body)");
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
else {
|
|
844
|
+
draft.drop(source, raw, typeof raw === 'number'
|
|
845
|
+
? 'rol tanınmadı — sayısal değer için ad spacing/space/gap, radius/rounded ya da font/size taşımalı'
|
|
846
|
+
: 'rol tanınmadı — metin değeri renk (#…, rgb) ya da yazı tipi ailesi (font/family/…) değil');
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
// Koyu küme: yalnız renkler.
|
|
850
|
+
for (const [source, raw] of Object.entries(snapshot.variablesDark ?? {})) {
|
|
851
|
+
if (hints.has(source))
|
|
852
|
+
continue;
|
|
853
|
+
if (looksLikeColor(raw))
|
|
854
|
+
mapColorByName(draft, source, raw, 'paletteDark');
|
|
855
|
+
else
|
|
856
|
+
draft.drop(source, raw, 'koyu kümede yalnız renk okunur — ölçek ve yazı tipi moda göre ayrılmaz');
|
|
857
|
+
}
|
|
858
|
+
if (options.deriveDark === true)
|
|
859
|
+
deriveDarkPalette(draft, snapshot);
|
|
860
|
+
// Metin stilleri: tipografi kaynağı olarak birleşir (değişkenlerle aynı havuz; alan başına kaynak).
|
|
861
|
+
for (const style of snapshot.textStyles ?? []) {
|
|
862
|
+
const role = style.role ?? words(style.name).map(word => ROLE_WORDS[word]).find(level => level !== undefined);
|
|
863
|
+
typography.add(role, style.name, style.fontSize, {
|
|
864
|
+
family: { value: style.fontFamily, source: style.name, raw: style.fontFamily },
|
|
865
|
+
size: { value: style.fontSize, source: style.name, raw: style.fontSize },
|
|
866
|
+
...(style.fontWeight === undefined ? {} : { weight: { value: style.fontWeight, source: style.name, raw: style.fontWeight } })
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
mapLengths(draft, spacing, 'spacing', gaps);
|
|
870
|
+
mapLengths(draft, radius, 'radius', gaps);
|
|
871
|
+
mapTypography(draft, [...typography.records.values()], gaps);
|
|
872
|
+
mapShadow(draft, snapshot, gaps);
|
|
873
|
+
mapComponents(draft, snapshot);
|
|
874
|
+
const theme = draft.theme();
|
|
875
|
+
if (theme.paletteDark === undefined)
|
|
876
|
+
gaps.add('paletteDark');
|
|
877
|
+
return {
|
|
878
|
+
theme,
|
|
879
|
+
mapping: draft.mapping,
|
|
880
|
+
unmapped: draft.unmapped,
|
|
881
|
+
gaps: [...gaps],
|
|
882
|
+
normalized: draft.normalized
|
|
883
|
+
};
|
|
884
|
+
}
|