@appaflytech/zeyrek-ai-mcp 0.0.49 → 0.0.51

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,331 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { copyFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import express from 'express';
7
+ import { MAX_IMAGES_PER_SLOT } from '../../knowledge/mobileStore.js';
8
+ import { cardHtml, renderCards, storeCanvas } from './compose.js';
9
+ import { EYEBROW_MAX, MAX_CARDS, SUBTITLE_MAX, TITLE_MAX, buildDeck, cardFileName, duplicateCard, parseCardSpec, saveDeck } from './deck.js';
10
+ import { LAYOUT_IDS, STORE_ART_LAYOUTS } from './layouts.js';
11
+ import { STORE_ART_STYLES, STYLE_IDS } from './styles.js';
12
+ const editorDirectory = fileURLToPath(new URL('../../resources/store-art/', import.meta.url));
13
+ const editorAssets = ['editor.css', 'editor.js'];
14
+ /**
15
+ * Önizleme `srcdoc` iframe'i bu politikayı devralır: kare HTML'i satır içi `<style>` taşır, betik taşımaz.
16
+ * Betik yalnız `editor.js`'tir.
17
+ */
18
+ const contentSecurityPolicy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'";
19
+ /**
20
+ * En büyük geçerli `render` gövdesi: 40 kart × en uzun metinler (UTF-8'de karakter başına en çok 4 bayt) + spec'in
21
+ * geri kalanı. Sabit 16 kb, en uzun Türkçe metinli 40 kartlık desteyi (24 kb) 413'le reddediyordu (taze inceleme).
22
+ */
23
+ const bodyLimit = MAX_CARDS * ((EYEBROW_MAX + TITLE_MAX + SUBTITLE_MAX) * 4 + 512);
24
+ const pendingName = (card) => `.pending-${cardFileName(card)}`;
25
+ const escapeHtml = (value) => value.replace(/[&<>"']/g, character => `&#${character.charCodeAt(0)};`);
26
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
27
+ const uniqueStrings = (value) => Array.isArray(value) && value.length > 0 && value.every(item => typeof item === 'string') && new Set(value).size === value.length;
28
+ /** Diskteki `index.html`: sunucu kapandıktan sonra da açılır, salt görüntüdür */
29
+ export function staticGalleryHtml(deck, uploadEnabled) {
30
+ const canvas = storeCanvas(deck.deviceClass);
31
+ const note = uploadEnabled
32
+ ? 'Bu sayfa dosyadan açıldı ve salt görüntüdür: düzenleme ve yükleme mobile_asset_preview yanıtındaki galleryUrl adresinden yapılır.'
33
+ : 'Yükleme kapalı: gateway AUTH_MODE=passthrough ile çalışıyor. Kareleri mobile_asset_create { path } ile yükleyin.';
34
+ const figures = deck.cards.map(card => [
35
+ `<figure><img src="${escapeHtml(cardFileName(card))}" width="${canvas.width}" height="${canvas.height}" alt="">`,
36
+ `<figcaption>Kare ${card.slide + 1} · ${escapeHtml(STORE_ART_STYLES[card.spec.style].label)}</figcaption></figure>`
37
+ ].join('')).join('');
38
+ return [
39
+ '<!doctype html><html lang="tr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">',
40
+ `<title>Mağaza görselleri · ${escapeHtml(deck.slot)}</title><style>`,
41
+ ':root{--bg:#f6f6f4;--text:#1b1b1b;--muted:#6b6b6b}@media (prefers-color-scheme:dark){:root{--bg:#141414;--text:#ececec;--muted:#9a9a9a}}',
42
+ 'body{margin:0;padding:24px;background:var(--bg);color:var(--text);font:15px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}',
43
+ 'h1{font-size:20px;margin:0 0 4px}.meta{color:var(--muted);font-size:13px;margin:0 0 20px}',
44
+ 'main{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:20px}figure{margin:0}img{display:block;width:100%;height:auto;border-radius:12px}',
45
+ 'figcaption{font-size:13px;color:var(--muted);padding-top:6px}',
46
+ '</style></head><body>',
47
+ `<h1>Mağaza görselleri</h1><p class="meta">Yuva <code>${escapeHtml(deck.slot)}</code> · ${deck.cards.length} kart · ${canvas.width}×${canvas.height}<br>${note}</p>`,
48
+ `<main>${figures}</main></body></html>`
49
+ ].join('\n');
50
+ }
51
+ export class GalleryServer {
52
+ galleries = new Map();
53
+ origin;
54
+ server;
55
+ async open(key, gallery) {
56
+ this.galleries.set(key, { ...gallery, uploaded: new Map(), queue: Promise.resolve() });
57
+ this.origin ??= this.listen().catch(error => {
58
+ this.origin = undefined;
59
+ throw error;
60
+ });
61
+ return `${await this.origin}/g/${key}/`;
62
+ }
63
+ async close() {
64
+ const server = this.server;
65
+ this.server = undefined;
66
+ this.origin = undefined;
67
+ await new Promise(resolve => server ? server.close(() => resolve()) : resolve());
68
+ }
69
+ listen() {
70
+ const app = express();
71
+ let origin = '';
72
+ app.disable('x-powered-by');
73
+ app.use((request, response, next) => {
74
+ if (request.headers.host !== new URL(origin).host) {
75
+ response.status(421).json({ error: `Bu sunucu yalnız ${origin} adresinden açılır.` });
76
+ return;
77
+ }
78
+ response.set({ 'Cache-Control': 'no-store', 'Content-Security-Policy': contentSecurityPolicy, 'X-Content-Type-Options': 'nosniff' });
79
+ next();
80
+ });
81
+ app.post('/g/:key/:action', express.json({ limit: bodyLimit }), (request, response) => {
82
+ void this.post(request.params.key, request.params.action, request, origin)
83
+ .catch((error) => ({ status: 500, body: { error: error instanceof Error ? error.message : String(error) } }))
84
+ .then(reply => 'html' in reply ? response.status(reply.status).type('html').send(reply.html) : response.status(reply.status).json(reply.body));
85
+ });
86
+ app.get('/g/:key/:file?', (request, response) => {
87
+ const gallery = this.galleries.get(request.params.key);
88
+ if (!gallery) {
89
+ response.status(404).type('text').send('Galeri yok: gateway yeniden başlamış olabilir. mobile_asset_preview yeniden çağrılır.');
90
+ return;
91
+ }
92
+ const file = request.params.file;
93
+ if (file === undefined) {
94
+ if (request.path.endsWith('/'))
95
+ response.sendFile(path.join(editorDirectory, 'editor.html'));
96
+ else
97
+ response.redirect(`/g/${request.params.key}/`);
98
+ return;
99
+ }
100
+ if (file === 'deck') {
101
+ response.json(deckView(gallery));
102
+ return;
103
+ }
104
+ if (editorAssets.includes(file)) {
105
+ response.sendFile(path.join(editorDirectory, file));
106
+ return;
107
+ }
108
+ // Yalnız bu destenin kart ve ham kare dosyaları sunulur; ad listeden seçildiği için yol geçişi olamaz.
109
+ if (!gallery.deck.cards.some(card => cardFileName(card) === file) && !gallery.deck.raws.some(raw => raw.name === file)) {
110
+ response.status(404).type('text').send('Dosya yok.');
111
+ return;
112
+ }
113
+ response.sendFile(path.join(gallery.directory, file));
114
+ });
115
+ // Bozuk JSON gövdesi Express'in HTML hata sayfasına düşüyordu; editör JSON hata bekler. Durum kodu hatanın
116
+ // kendisinden gelir (body-parser 400/413, sendFile 404).
117
+ app.use((error, _request, response, _next) => {
118
+ const status = error.status;
119
+ response.status(typeof status === 'number' ? status : 500).json({ error: error instanceof Error ? error.message : String(error) });
120
+ });
121
+ return new Promise((resolve, reject) => {
122
+ const server = app.listen(0, '127.0.0.1');
123
+ server.once('error', reject);
124
+ server.once('listening', () => {
125
+ // stdio sürecini bu sunucu ayakta tutmaz: istemci kapanınca gateway de kapanır.
126
+ server.unref();
127
+ origin = `http://127.0.0.1:${server.address().port}`;
128
+ this.server = server;
129
+ resolve(origin);
130
+ });
131
+ });
132
+ }
133
+ async post(key, action, request, origin) {
134
+ const gallery = this.galleries.get(key);
135
+ if (!gallery)
136
+ return { status: 404, body: { error: 'Galeri yok: gateway yeniden başlamış olabilir.' } };
137
+ if (request.headers.origin !== origin)
138
+ return { status: 403, body: { error: 'İstek yalnız galeri sayfasının kendisinden yapılır.' } };
139
+ if (!request.is('application/json'))
140
+ return { status: 415, body: { error: 'Gövde application/json olmalı.' } };
141
+ const body = isObject(request.body) ? request.body : {};
142
+ if (action === 'preview')
143
+ return preview(gallery, key, body);
144
+ if (action === 'render')
145
+ return render(gallery, body);
146
+ if (action === 'order')
147
+ return order(gallery, body);
148
+ if (action === 'duplicate')
149
+ return duplicate(gallery, body);
150
+ if (action === 'upload')
151
+ return upload(gallery, body);
152
+ return { status: 404, body: { error: `Bilinmeyen işlem '${action}'.` } };
153
+ }
154
+ }
155
+ /**
156
+ * Galeriye yazan işler (çizim, sıra, kopya, yükleme) sırayla koşar: aynı anda iki çizim üç profil yuvasını
157
+ * tüketir, yükleme yarım yazılmış bir JPEG'i okuyabilirdi.
158
+ */
159
+ function queued(gallery, task) {
160
+ const next = gallery.queue.then(task, task);
161
+ gallery.queue = next.catch(() => undefined);
162
+ return next;
163
+ }
164
+ const cardView = (gallery, card) => ({ ...card, uploadedVersion: gallery.uploaded.get(card.name) });
165
+ function deckView(gallery) {
166
+ const { deck } = gallery;
167
+ return {
168
+ slot: deck.slot,
169
+ deviceClass: deck.deviceClass,
170
+ locale: deck.locale,
171
+ canvas: storeCanvas(deck.deviceClass),
172
+ maxPerSlot: MAX_IMAGES_PER_SLOT,
173
+ limits: { title: TITLE_MAX, subtitle: SUBTITLE_MAX, eyebrow: EYEBROW_MAX, cards: MAX_CARDS },
174
+ styles: STYLE_IDS.map(id => ({ id, label: STORE_ART_STYLES[id].label, colors: STORE_ART_STYLES[id].colors })),
175
+ layouts: LAYOUT_IDS.map(id => ({ id, needsScreen: STORE_ART_LAYOUTS[id].needsScreen })),
176
+ raws: deck.raws.map(({ name, width, height }) => ({ name, width, height })),
177
+ cards: deck.cards.map(card => cardView(gallery, card))
178
+ };
179
+ }
180
+ /** Önizleme son JPEG'i çizen HTML'in kendisidir; ham kare galeri adresinden okunur. Hiçbir şey yazmaz. */
181
+ function preview(gallery, key, body) {
182
+ const parsed = parseCardSpec(gallery.deck, body.spec);
183
+ if ('error' in parsed)
184
+ return { status: 400, body: { error: parsed.error } };
185
+ return { status: 200, html: cardHtml(gallery.deck, parsed.spec, raw => `/g/${key}/${raw.name}`) };
186
+ }
187
+ async function render(gallery, body) {
188
+ const items = body.cards;
189
+ if (!Array.isArray(items) || items.length === 0 || items.length > MAX_CARDS) {
190
+ return { status: 400, body: { error: `cards 1–${MAX_CARDS} öğeli { name, spec } dizisi olmalı.` } };
191
+ }
192
+ const targets = [];
193
+ for (const [index, item] of items.entries()) {
194
+ // Kartlar desteden hiç çıkmaz (yalnız eklenir); burada bulunan kart kuyrukta da geçerlidir.
195
+ const card = isObject(item) ? gallery.deck.cards.find(candidate => candidate.name === item.name) : undefined;
196
+ if (!card || targets.some(target => target.card === card))
197
+ return { status: 400, body: { error: `cards[${index}].name destede yok ya da tekrar ediyor.` } };
198
+ const version = item.version;
199
+ if (version !== undefined && !Number.isInteger(version))
200
+ return { status: 400, body: { error: `cards[${index}].version tam sayı olmalı.` } };
201
+ const parsed = parseCardSpec(gallery.deck, item.spec);
202
+ if ('error' in parsed)
203
+ return { status: 400, body: { error: `cards[${index}] (${card.name}) — ${parsed.error}` } };
204
+ targets.push({ card, spec: parsed.spec, ...(version === undefined ? {} : { version: version }) });
205
+ }
206
+ return queued(gallery, async () => {
207
+ // İki sekme aynı kartı düzenliyorsa eski sürümden gelen çizim yenisini sessizce ezmez.
208
+ const stale = targets.find(target => target.version !== undefined && target.version !== target.card.version);
209
+ if (stale)
210
+ return { status: 409, body: { error: `${stale.card.name} başka bir sekmede yeniden çizildi (sürüm ${stale.card.version}); sayfayı yenileyip yeniden deneyin.` } };
211
+ // Çizim geçici dosyalara yapılır; spec, sürüm ve dosya ancak bütün kartlar çizilince birlikte değişir. Önceden
212
+ // yarıda düşen çizim ilk kartın JPEG'ini ezip sürümü geri alıyordu ve yükleme yeni dosyayı eski sürümle gönderirdi.
213
+ const drafts = targets.map(target => ({ ...target.card, spec: target.spec }));
214
+ try {
215
+ await gallery.render(drafts, pendingName);
216
+ }
217
+ catch (error) {
218
+ await Promise.all(drafts.map(card => rm(path.join(gallery.directory, pendingName(card)), { force: true })));
219
+ throw error;
220
+ }
221
+ for (const target of targets) {
222
+ await rename(path.join(gallery.directory, pendingName(target.card)), path.join(gallery.directory, cardFileName(target.card)));
223
+ target.card.spec = target.spec;
224
+ target.card.version += 1;
225
+ }
226
+ await persist(gallery);
227
+ return { status: 200, body: { cards: targets.map(target => cardView(gallery, target.card)) } };
228
+ });
229
+ }
230
+ /** Sıra yükleme sırasıdır; gövde destenin BÜTÜN kart adlarını yeni sırayla taşır */
231
+ async function order(gallery, body) {
232
+ const names = body.cards;
233
+ if (!uniqueStrings(names))
234
+ return { status: 400, body: { error: 'cards tekrarsız kart adlarından oluşan boş olmayan bir dizi olmalı.' } };
235
+ // Deste kuyrukta denetlenir: önünde bekleyen bir kopya desteyi büyütmüş olabilir. Dışarıda denetlenen eski sıra
236
+ // kopyayı desteden düşürüyordu (taze inceleme 2026-10-26).
237
+ return queued(gallery, async () => {
238
+ const byName = new Map(gallery.deck.cards.map(card => [card.name, card]));
239
+ if (names.some(name => !byName.has(name)))
240
+ return { status: 400, body: { error: 'cards destede olmayan bir ad taşıyor.' } };
241
+ if (names.length !== byName.size)
242
+ return { status: 409, body: { error: `Deste bu arada değişti ya da liste eksik: destede ${byName.size} kart var, sıra ${names.length} ad taşıyor. Desteyi yeniden okuyup gönderin.` } };
243
+ gallery.deck.cards = names.map(name => byName.get(name));
244
+ await persist(gallery);
245
+ return { status: 200, body: { cards: names } };
246
+ });
247
+ }
248
+ /** Kopya aynı spec ve aynı JPEG'le başlar (dosya kopyalanır, yeniden çizilmez); asılın hemen arkasına girer */
249
+ async function duplicate(gallery, body) {
250
+ // Sınır kuyrukta denetlenir: eşzamanlı iki kopya dışarıda aynı sayımı görüp 41. kartı açıyordu.
251
+ return queued(gallery, async () => {
252
+ const original = gallery.deck.cards.find(card => card.name === body.card);
253
+ if (!original)
254
+ return { status: 400, body: { error: 'card destede yok.' } };
255
+ if (gallery.deck.cards.length >= MAX_CARDS)
256
+ return { status: 400, body: { error: `Deste en çok ${MAX_CARDS} kart alır.` } };
257
+ const copy = duplicateCard(gallery.deck, original);
258
+ await copyFile(path.join(gallery.directory, cardFileName(original)), path.join(gallery.directory, cardFileName(copy)));
259
+ gallery.deck.cards.splice(gallery.deck.cards.indexOf(original) + 1, 0, copy);
260
+ await persist(gallery);
261
+ return { status: 200, body: { card: cardView(gallery, copy) } };
262
+ });
263
+ }
264
+ async function upload(gallery, body) {
265
+ const names = body.files;
266
+ const byFile = new Map(gallery.deck.cards.map(card => [cardFileName(card), card]));
267
+ if (!uniqueStrings(names) || names.some(name => !byFile.has(name))) {
268
+ return { status: 400, body: { error: 'files bu destenin dosya adlarından (ör. clean-01.jpg) oluşan, tekrarsız ve boş olmayan bir dizi olmalı.' } };
269
+ }
270
+ return queued(gallery, async () => {
271
+ // Mağazadaki sıra yükleme sırasıdır: seçim sırası değil, deste sırası — kuyrukta okunur, önündeki sıra değişikliği geçerlidir.
272
+ const chosen = gallery.deck.cards.filter(card => names.includes(cardFileName(card)));
273
+ const results = [];
274
+ for (const card of chosen) {
275
+ const file = cardFileName(card);
276
+ const version = card.version;
277
+ // Aynı sürümü ikinci kez yüklemek yuvaya kopya ekler; sayfa yenilense de sunucu hatırlar. Kart yeniden
278
+ // çizilince yeni sürüm yüklenebilir (öncekisi yuvada kalır).
279
+ if (gallery.uploaded.get(card.name) === version) {
280
+ results.push({ file, ok: false, error: 'Bu kare bu galeriden zaten yüklendi.' });
281
+ continue;
282
+ }
283
+ const before = gallery.uploaded.get(card.name);
284
+ gallery.uploaded.set(card.name, version);
285
+ try {
286
+ const asset = await gallery.upload(path.join(gallery.directory, file));
287
+ results.push({ file, ok: true, ...(typeof asset.id === 'string' ? { assetId: asset.id } : {}) });
288
+ }
289
+ catch (error) {
290
+ if (before === undefined)
291
+ gallery.uploaded.delete(card.name);
292
+ else
293
+ gallery.uploaded.set(card.name, before);
294
+ results.push({ file, ok: false, error: error instanceof Error ? error.message : String(error) });
295
+ }
296
+ }
297
+ return { status: 200, body: { results } };
298
+ });
299
+ }
300
+ /** Deste değişince `deck.json` ile salt görüntü `index.html` birlikte yazılır (galeri yalnız AUTH_MODE=local'da açılır) */
301
+ async function persist(gallery) {
302
+ await saveDeck(gallery.directory, gallery.deck);
303
+ await writeFile(path.join(gallery.directory, 'index.html'), staticGalleryHtml(gallery.deck, true));
304
+ }
305
+ export const sharedGalleryServer = new GalleryServer();
306
+ /**
307
+ * Desteyi kurar, kartları çizer, `deck.json` ve salt görüntü `index.html`'i diske yazar ve (AUTH_MODE=local'da)
308
+ * editörü açar. Dizin `MCP_STATE_DIR` altındadır (yoksa işletim sisteminin geçici dizini); `sendFile` mutlak yol
309
+ * istediği için çözülür — kök `.env`'deki `MCP_STATE_DIR` göreli bir yoldur.
310
+ */
311
+ export async function previewStoreArt(launch, config, gallery, input, upload) {
312
+ const key = randomBytes(16).toString('hex');
313
+ const directory = path.resolve(config.stateDirectory ?? path.join(os.tmpdir(), 'zeyrek-mcp'), 'store-art', key);
314
+ const { deck, warnings } = await buildDeck(input, directory);
315
+ const renderDeck = (cards, fileName) => renderCards(launch, deck, directory, cards, fileName);
316
+ const files = await renderDeck(deck.cards);
317
+ // passthrough'ta platform kimliği MCP isteğinden gelir; tarayıcıdan gelen yükleme isteğinin kimliği yoktur.
318
+ const uploadEnabled = config.authMode === 'local';
319
+ await saveDeck(directory, deck);
320
+ await writeFile(path.join(directory, 'index.html'), staticGalleryHtml(deck, uploadEnabled));
321
+ const galleryUrl = uploadEnabled ? await gallery.open(key, { directory, deck, upload, render: renderDeck }) : undefined;
322
+ return {
323
+ slot: deck.slot,
324
+ ...(galleryUrl
325
+ ? { galleryUrl, galleryNote: 'Adresi kullanıcıya verin: sayfa bir editördür — başlığı, stili ve yerleşimi orada değiştirip kartı yeniden çizer, seçtiklerini yükler. Kartların son hâli directory/deck.json\'dadır. Galeri gateway süreci yaşadıkça açıktır.' }
326
+ : { galleryNote: `AUTH_MODE=passthrough: galeri sunucusu açılmaz. Sayfa salt görüntü olarak ${path.join(directory, 'index.html')}'dedir; yükleme mobile_asset_create { path } ile.` }),
327
+ directory,
328
+ files,
329
+ ...(warnings.length > 0 ? { warnings } : {})
330
+ };
331
+ }
@@ -0,0 +1,121 @@
1
+ import { SCREENSHOT_CAPTURE_DEVICES, STORE_FRAME_LAYOUTS } from '../../knowledge/mobileStore.js';
2
+ /**
3
+ * Pazarlama karesinin yerleşim kaydı (ADR 0005): her satır tuvaldeki kutuları (başlık, cihaz, ekran, çentik) ve
4
+ * cihaza uygulanan CSS dönüşümünü hesaplayan saf bir fonksiyondur. Çizim (`slideHtml`) yerleşime göre dallanmaz;
5
+ * yeni yerleşim bu tabloya bir satırdır. Ölçüler tuvalin pikselidir; tuval mağaza ölçüsüdür.
6
+ */
7
+ export const LAYOUT_IDS = ['classic', 'bleed', 'tilt', 'text'];
8
+ const edgeShare = 0.04;
9
+ const captionShare = 0.22;
10
+ const gapShare = 0.02;
11
+ const captionInsetShare = 0.08;
12
+ const unframedRadiusShare = 0.06;
13
+ const bleedWidthShare = 0.86;
14
+ const tiltWidthFactor = 0.86;
15
+ export function storeCanvas(deviceClass) {
16
+ const device = SCREENSHOT_CAPTURE_DEVICES[deviceClass];
17
+ return { width: device.width * device.scale, height: device.height * device.scale };
18
+ }
19
+ const heightPerWidth = (deviceClass, frame) => {
20
+ const capture = SCREENSHOT_CAPTURE_DEVICES[deviceClass];
21
+ const bezel = frame ? STORE_FRAME_LAYOUTS[deviceClass].bezel : 0;
22
+ return (1 - 2 * bezel) * (capture.height / capture.width) + 2 * bezel;
23
+ };
24
+ /** Dynamic Island ve kamera deliğinin ekran genişliğine oranı (iPhone 16 Pro Max 126×37 pt / 440 pt) */
25
+ function cutoutBox(cutout, screen) {
26
+ const width = Math.round(screen.width * (cutout === 'island' ? 0.29 : 0.035));
27
+ const height = Math.round(screen.width * (cutout === 'island' ? 0.085 : 0.035));
28
+ return { x: screen.x + Math.round((screen.width - width) / 2), y: screen.y + Math.round(screen.width * 0.026), width, height, radius: Math.round(height / 2) };
29
+ }
30
+ /** Sol üst köşesi ve genişliği verilen cihazın kutuları; ekranın en-boy oranı ham karenin oranıdır */
31
+ function phoneBoxes(deviceClass, frame, x, y, width) {
32
+ const layout = STORE_FRAME_LAYOUTS[deviceClass];
33
+ const height = Math.round(width * heightPerWidth(deviceClass, frame));
34
+ const bezelPx = Math.round(width * (frame ? layout.bezel : 0));
35
+ const deviceRadius = Math.round(width * layout.radius);
36
+ const screen = {
37
+ x: x + bezelPx,
38
+ y: y + bezelPx,
39
+ width: width - 2 * bezelPx,
40
+ height: height - 2 * bezelPx,
41
+ radius: frame ? Math.max(deviceRadius - bezelPx, 0) : Math.round(width * unframedRadiusShare)
42
+ };
43
+ return {
44
+ ...(frame ? { device: { x, y, width, height, radius: deviceRadius } } : {}),
45
+ screen,
46
+ ...(frame && layout.cutout !== 'none' ? { cutout: cutoutBox(layout.cutout, screen) } : {})
47
+ };
48
+ }
49
+ function bands(canvas) {
50
+ const edge = Math.round(canvas.height * edgeShare);
51
+ const gap = Math.round(canvas.height * gapShare);
52
+ const captionHeight = Math.round(canvas.height * captionShare);
53
+ const inset = Math.round(canvas.width * captionInsetShare);
54
+ // iPad'de (geniş tuval) üst etiket + iki satır başlık + iki satır alt başlık başlık alanını taşırıyordu: 0.21.
55
+ const titleSize = Math.round(Math.min(canvas.width * 0.072, captionHeight * 0.21));
56
+ return { edge, gap, captionHeight, inset, titleSize };
57
+ }
58
+ const captionSizes = (titleSize) => ({ titleSize, subtitleSize: Math.round(titleSize * 0.5), eyebrowSize: Math.round(titleSize * 0.36) });
59
+ /**
60
+ * Cihaz başlık alanının dışında kalan bölgeye SIĞAR: genişlik çerçeve tablosundaki üst sınırla bölge yüksekliğinin
61
+ * küçüğüdür (`widthFactor` ile küçültülebilir).
62
+ */
63
+ function centred({ deviceClass, frame, captionPosition }, widthFactor) {
64
+ const canvas = storeCanvas(deviceClass);
65
+ const { edge, gap, captionHeight, inset, titleSize } = bands(canvas);
66
+ const space = canvas.height - 2 * edge - captionHeight - gap;
67
+ const width = Math.round(Math.min(canvas.width * STORE_FRAME_LAYOUTS[deviceClass].deviceWidth, space / heightPerWidth(deviceClass, frame)) * widthFactor);
68
+ const height = Math.round(width * heightPerWidth(deviceClass, frame));
69
+ const regionTop = captionPosition === 'top' ? edge + captionHeight + gap : edge;
70
+ return {
71
+ canvas,
72
+ caption: { x: inset, y: captionPosition === 'top' ? edge : canvas.height - edge - captionHeight, width: canvas.width - 2 * inset, height: captionHeight },
73
+ ...phoneBoxes(deviceClass, frame, Math.round((canvas.width - width) / 2), regionTop + Math.round((space - height) / 2), width),
74
+ ...captionSizes(titleSize),
75
+ titleLines: 2
76
+ };
77
+ }
78
+ export const STORE_ART_LAYOUTS = {
79
+ classic: { needsScreen: true, geometry: context => centred(context, 1) },
80
+ /** Büyük cihaz başlığın altından başlar ve alt kenardan taşar; başlık her zaman üsttedir */
81
+ bleed: {
82
+ needsScreen: true,
83
+ geometry: ({ deviceClass, frame }) => {
84
+ const canvas = storeCanvas(deviceClass);
85
+ const { edge, gap, captionHeight, inset, titleSize } = bands(canvas);
86
+ const width = Math.round(canvas.width * Math.max(STORE_FRAME_LAYOUTS[deviceClass].deviceWidth, bleedWidthShare));
87
+ return {
88
+ canvas,
89
+ caption: { x: inset, y: edge, width: canvas.width - 2 * inset, height: captionHeight },
90
+ ...phoneBoxes(deviceClass, frame, Math.round((canvas.width - width) / 2), edge + captionHeight + gap, width),
91
+ ...captionSizes(titleSize),
92
+ titleLines: 2
93
+ };
94
+ }
95
+ },
96
+ /** `classic`'in biraz küçüğü, perspektifle sola dönük; dönüşüm kutuyu bölgesinin dışına taşırmasın diye küçülür */
97
+ tilt: {
98
+ needsScreen: true,
99
+ geometry: context => ({
100
+ ...centred(context, tiltWidthFactor),
101
+ phoneTransform: `perspective(${storeCanvas(context.deviceClass).width * 3}px) rotateY(-16deg) rotateX(5deg) rotateZ(-3deg)`
102
+ })
103
+ },
104
+ /** Cihazsız kare: üst etiket, büyük başlık, alt başlık tuvalin ortasında; ham kare istemez */
105
+ text: {
106
+ needsScreen: false,
107
+ geometry: ({ deviceClass }) => {
108
+ const canvas = storeCanvas(deviceClass);
109
+ const { inset } = bands(canvas);
110
+ const titleSize = Math.round(Math.min(canvas.width * 0.12, canvas.height * 0.06));
111
+ return {
112
+ canvas,
113
+ caption: { x: inset, y: Math.round(canvas.height * 0.18), width: canvas.width - 2 * inset, height: Math.round(canvas.height * 0.64) },
114
+ titleSize,
115
+ subtitleSize: Math.round(titleSize * 0.42),
116
+ eyebrowSize: Math.round(titleSize * 0.3),
117
+ titleLines: 5
118
+ };
119
+ }
120
+ }
121
+ };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Pazarlama karesinin stil kaydı (ADR 0005): her satır renk, arka plan, yazı ve sahne verisidir. Çizim
3
+ * (`slideHtml`) stile göre dallanmaz; arka plan türü `BACKDROPS`'tan, yazı ailesi `FONT_STACKS`'ten okunur.
4
+ * Yazı tipleri yalnız işletim sisteminindir (paket yazı tipi yok); aynı stil macOS'ta ve Linux'ta farklı
5
+ * yazı tipiyle çizilebilir.
6
+ */
7
+ export const STYLE_IDS = ['clean', 'midnight', 'aurora', 'spotlight', 'swiss', 'japandi', 'pastel', 'neon'];
8
+ export const BACKDROP_KINDS = ['solid', 'gradient', 'aurora', 'spotlight', 'grid', 'dots'];
9
+ export const FONT_STACKS = {
10
+ sans: '-apple-system,BlinkMacSystemFont,"SF Pro Display","Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif',
11
+ serif: 'ui-serif,"New York","Iowan Old Style",Georgia,"Times New Roman",serif',
12
+ rounded: 'ui-rounded,"SF Pro Rounded","Hiragino Maru Gothic ProN",Nunito,-apple-system,sans-serif'
13
+ };
14
+ /** Arka plan türü → CSS `background` değeri. Renkler `#RRGGBB`'dir; sona eklenen iki hane alfadır (`#RRGGBBAA`). */
15
+ export const BACKDROPS = {
16
+ solid: colors => colors.base,
17
+ gradient: colors => `linear-gradient(180deg, ${colors.base}, ${colors.second})`,
18
+ aurora: colors => [
19
+ `radial-gradient(60% 38% at 18% 12%, ${colors.accent}aa 0%, transparent 70%)`,
20
+ `radial-gradient(55% 35% at 88% 38%, ${colors.second}cc 0%, transparent 70%)`,
21
+ `radial-gradient(75% 45% at 45% 100%, ${colors.accent}77 0%, transparent 70%)`,
22
+ colors.base
23
+ ].join(', '),
24
+ spotlight: colors => `radial-gradient(60% 38% at 50% 58%, ${colors.second} 0%, ${colors.base} 78%)`,
25
+ grid: (colors, canvas) => {
26
+ const step = Math.round(canvas.width / 12);
27
+ const line = Math.max(Math.round(canvas.width / 600), 2);
28
+ return [
29
+ `repeating-linear-gradient(0deg, ${colors.second} 0 ${line}px, transparent ${line}px ${step}px)`,
30
+ `repeating-linear-gradient(90deg, ${colors.second} 0 ${line}px, transparent ${line}px ${step}px)`,
31
+ colors.base
32
+ ].join(', ');
33
+ },
34
+ dots: (colors, canvas) => {
35
+ const step = Math.round(canvas.width / 24);
36
+ const dot = Math.max(Math.round(canvas.width / 440), 2);
37
+ return `radial-gradient(circle, ${colors.second} ${dot}px, transparent ${dot + 1}px) 0 0 / ${step}px ${step}px, ${colors.base}`;
38
+ }
39
+ };
40
+ export const STORE_ART_STYLES = {
41
+ clean: { label: 'Clean', backdrop: 'solid', colors: { base: '#F5F5F2', second: '#E6E6E1', accent: '#1F6FEB', text: '#111111' }, font: 'sans', titleWeight: 800, titleCase: 'none', align: 'center', shadow: 0.25, glow: false, frameColor: '#1A1A1C' },
42
+ midnight: { label: 'Midnight', backdrop: 'gradient', colors: { base: '#0B1026', second: '#1F2A5E', accent: '#7C9CFF', text: '#FFFFFF' }, font: 'sans', titleWeight: 800, titleCase: 'none', align: 'center', shadow: 0.5, glow: true, frameColor: '#0B0B0D' },
43
+ aurora: { label: 'Aurora', backdrop: 'aurora', colors: { base: '#0E0B1F', second: '#2E8B9A', accent: '#B45CFF', text: '#FFFFFF' }, font: 'sans', titleWeight: 800, titleCase: 'none', align: 'center', shadow: 0.45, glow: true, frameColor: '#0B0B0D' },
44
+ spotlight: { label: 'Spotlight', backdrop: 'spotlight', colors: { base: '#08080A', second: '#34343F', accent: '#FFC857', text: '#FFFFFF' }, font: 'sans', titleWeight: 700, titleCase: 'none', align: 'center', shadow: 0.6, glow: false, frameColor: '#141416' },
45
+ swiss: { label: 'Swiss', backdrop: 'grid', colors: { base: '#F2F0EA', second: '#DCD8CD', accent: '#E4002B', text: '#111111' }, font: 'sans', titleWeight: 900, titleCase: 'uppercase', align: 'left', shadow: 0.2, glow: false, frameColor: '#111111' },
46
+ japandi: { label: 'Japandi', backdrop: 'solid', colors: { base: '#EFEAE1', second: '#E2DBCF', accent: '#8A7A63', text: '#2B2A27' }, font: 'serif', titleWeight: 400, titleCase: 'none', align: 'center', shadow: 0.15, glow: false, frameColor: '#2B2A27' },
47
+ pastel: { label: 'Pastel', backdrop: 'gradient', colors: { base: '#FFD9E8', second: '#D6E4FF', accent: '#7A5CFA', text: '#2A2440' }, font: 'rounded', titleWeight: 800, titleCase: 'none', align: 'center', shadow: 0.2, glow: false, frameColor: '#2A2440' },
48
+ neon: { label: 'Neon', backdrop: 'dots', colors: { base: '#050505', second: '#262626', accent: '#39FF88', text: '#FFFFFF' }, font: 'sans', titleWeight: 900, titleCase: 'uppercase', align: 'center', shadow: 0.5, glow: true, frameColor: '#111111' }
49
+ };
50
+ /** `palette.primary` stilin vurgu rengini, `palette.text` başlık rengini uygulamanınkine çeker (tema `theme_get`) */
51
+ export function resolveStyle(id, palette = {}) {
52
+ const style = STORE_ART_STYLES[id];
53
+ return { ...style, colors: { ...style.colors, ...(palette.primary ? { accent: palette.primary } : {}), ...(palette.text ? { text: palette.text } : {}) } };
54
+ }