@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.
@@ -5,7 +5,7 @@ export const componentEventContracts = {
5
5
  mount: { payload: {}, reads: 'self.filters · self.sortField · self.sortType · self.offset · self.limit' },
6
6
  save: {
7
7
  payload: {},
8
- reads: "self.changeset = { inserted:[{ rowId, values }], updated:[{ rowId, id, patch }], deleted:[{ rowId, id }] } (komponent olaydan ÖNCE yazar). Bağlı tabloda (rows connectData) yazmayı runtime bağlantısı yapar, akış ardından koşar; hatalar self.rowErrors [{ rowId, field?, message }]. İşlemin bağlamı da olaydan ÖNCE yazılır (2026-09-21): self.operation (rowAdd|rowEdit|cellEdit|rowDelete|bulkEdit|bulkDelete|batchSave), self.editedRows (tam satırlar), self.deletedRows (silinen tam satırlar), self.editedCell ({ rowId, field, value, previousValue }, yalnız cellEdit), self.bulkPatch (yalnız bulkEdit). Her işlem için ayrı olay yoktur; tek save akışı self.operation ile ayırır"
8
+ reads: "self.changeset = { inserted:[{ rowId, values }], updated:[{ rowId, id, patch }], deleted:[{ rowId, id }] } (komponent olaydan ÖNCE yazar). Bağlı tabloda (rows connectData) yazmayı runtime bağlantısı yapar, akış ardından koşar; hatalar self.rowErrors [{ rowId, field?, message }]. İşlemin bağlamı da olaydan ÖNCE yazılır (2026-09-21): self.operation (rowAdd|rowEdit|cellEdit|rowDelete|bulkEdit|bulkDelete|batchSave|rowReorder), self.editedRows (tam satırlar), self.deletedRows (silinen tam satırlar), self.editedCell ({ rowId, field, value, previousValue }, yalnız cellEdit), self.bulkPatch (yalnız bulkEdit). Her işlem için ayrı olay yoktur; tek save akışı self.operation ile ayırır"
9
9
  },
10
10
  discard: { payload: {}, reads: 'self.discardedRowIds (vazgeçilen taslak satır kimlikleri)' },
11
11
  rowClick: { payload: {}, reads: 'self.selectedRow (tıklanan satırın sunucu satırı; kaydedilmemiş yeni satırda taslak değerleri). Veri hücresine tıklamada yayılır — onay kutusu, ✏/🗑 ve aksiyon düğmeleri tetiklemez; çift tık önce iki rowClick üretir' },
@@ -350,8 +350,9 @@ export const mobileComponentKnowledge = {
350
350
  'GRUPLAMA bu bileşende YOKTUR: başlıklar altında liste (türe/zamana göre görevler) `grouped-list`tir — sayaç başlık şablonunda `Section.count`, toplu eylem başlıktaki bir `button` (akışı `Section.items` okur).',
351
351
  'Düşenler: `cardFill` (web ızgarasında kartı en uzun kartın boyuna uzatır; mobil satır içeriğinin boyundadır).',
352
352
  'Çoklu seçim (MOBİLE ÖZGÜ): `selectionMode: multiple` satır başına onay kutusu çizer, dokunuş seçimi değiştirir ve `itemPress`/`change`/`select` YAYILMAZ; `selectionChange { selectedIndices, count }` yayılır. Seçim düğüme yazılır: `{{ <liste>.selectedItems.length }} seçildi`, `<liste>.selectedIndices`. `longPressSelects: true` uzun basışla kipi açar (basılan satır seçili). Kipi kapatmak: setter ile `selectionMode: \'single\'` + `selectedIndices: []` + `selectedItems: []`. Seçim başlığı ayrı bir `app-bar`dır, iki çubuk `isHidden` bağıyla yer değiştirir.',
353
- '`swipeActions` (eylemin `icon`u etiketin üstünde glif olarak çizilir) ve `itemPressable` MOBİLE ÖZGÜDÜR (webde yok). Jest RN `Animated` + `PanResponder` ile kurulur — paket v1`de `reanimated`/`gesture-handler` yok — ve yalnız YATAY harekette yakalanır, böylece dikey kaydırma kaba kalır. Tasarım kipinde satırlar şablon olarak çizildiği için jest hiç bağlanmaz.',
353
+ '`swipeActions` (eylemin `icon`u etiketin üstünde glif olarak çizilir) ve `itemPressable` MOBİLE ÖZGÜDÜR (webde yok). Jest RN `Animated` + `PanResponder` ile kurulur (`gesture-handler` kullanılmaz) ve yalnız YATAY harekette yakalanır, böylece dikey kaydırma kaba kalır. Tasarım kipinde satırlar şablon olarak çizildiği için jest hiç bağlanmaz.',
354
354
  '`swipe` ve `itemPress` yükleri satırı `row` alanında TAŞIR; akış ifadesindeki `Row`/`RowIndex` bağı ayrı bir konudur (ARC-121).',
355
+ 'SÜRÜKLE-SIRALA (MOBİLE ÖZGÜ, 2026-10-27): `reorderable: true` satır sonuna bir tutamaç (⠿) çizer; tutamaçtan sürüklenen satırı kardeşler kayarak yer açar, bırakınca `reorder { fromIndex, toIndex, row, patches }` yayılır. YALNIZ dikey ve tek sütunlu listede (yatay şerit ya da `columnCount` > 1 iken tutamaç çizilmez — MOB-035). `orderField` verilirse satırlar o sayı alanına göre çizilir (boşlar sonda) ve `patches` o alana yazılacak 1 tabanlı yeni sıraları taşır. Liste bırakılan sırayı FARKLI satırlar gelene kadar tutar: aynı satırlar yeniden okunursa (yenileme, aynı yanıt) sıra bozulmaz; kalıcılık akışın işidir ve servis hatasında liste eski sıraya kendiliğinden DÖNMEZ (`platform_guide(\'page\')` §Kural 1g-5). Kenar bandında sayfa kendiliğinden kayar; modal/alt sayfa içinde kaymaz. Tasarım kipinde tutamaç çizilir ama sürüklenmez. Tutamaç gövdeden ayrıdır: gövdedeki dokunuş `itemPress`, yatay kaydırma `swipe` olarak kalır. Erişilebilirlik: tutamaçta "yukarı taşı / aşağı taşı" eylemleri. Tutamacın rol adresi `<elementId>__row-<i>__handle` — yalnız çalışma anında; tasarım ve Düzenle kipinde kimlik basılmaz.',
355
356
  'Düşenler: `autoRowSize` (mobil satır her zaman içeriğinin boyundadır), `size` (mobil kart hücresi çocuklarını büyütmez; 2026-09-30), `cardFill` (kart hücre boyuna gerilmez, içeriğinin boyundadır; 2026-10-14).'
356
357
  ],
357
358
  propNotes: {
@@ -362,7 +363,9 @@ export const mobileComponentKnowledge = {
362
363
  snap: '`none` · `item` — kaydırma kart + aralık adımına oturur.',
363
364
  views: "Sabit iki görünüm: 'item' (satır şablonu) ve 'empty' (boş görünüm). Değer YAZILMAZ; görünümler children'daki iki view düğümüdür, başka şekil 422 `fixed-views`.",
364
365
  selectionMode: '`single` (varsayılan, bugünkü `selectedIndex`) · `multiple` (onay kutulu çoklu seçim, `selectionChange`) · `none` (seçim izlenmez).',
365
- longPressSelects: 'Uzun basış çoklu seçim kipini açar ve basılan satırı seçer.'
366
+ longPressSelects: 'Uzun basış çoklu seçim kipini açar ve basılan satırı seçer.',
367
+ reorderable: 'Tutamaçtan sürükleyerek sıralama (yalnız dikey, tek sütun); bırakınca `reorder` yayılır.',
368
+ orderField: "Sırayı tutan sayı alanının adı (ör. 'sira'); satırlar buna göre çizilir, `reorder.patches[].value` buraya yazılacak sıradır. Boşsa `patches` boş gelir."
366
369
  }
367
370
  },
368
371
  modal: {
@@ -997,7 +1000,7 @@ export const mobileComponentKnowledge = {
997
1000
  '`variant: table` (varsayılan) yatay kaydırılan tablodur: başlık satırı, sıralanabilir kolonda başlığa dokunuş (`<elementId>__sort-<kolon>`, yön oku başlıkta), eylem sütunu satırın sonunda; dikey kaydırma sayfanındır, tablonun üstünden başlayan dikey kaydırma sayfayı kaydırır; kolonların toplamı çerçeveden darsa kolonlar web gibi çerçeveye yayılır (`table-layout: fixed`). `variant: cards` satır başına kart çizer: ilk görünür görsel kolon kartın başına, ilk görsel olmayan kolon başlığa, kalanlar etiket-değer satırlarına; kart kipinde sıralama başlığı yoktur (sıralama akışla yazılır).',
998
1001
  'Eylemler web paletiyle (`solid` birincil, `information` kenarlı, `transparent` hayalet, `error`/`danger` yıkıcı, `warning`, `dark`): `actionType: button` satırda düğmeler (`<elementId>__action-<satır>-<sıra>`), `dropdown` ⋯ düğmesi (`<elementId>__more-<satır>`) alttan açılan eylem sayfası (`<elementId>__sheet-action-<sıra>`; cihazda `modal` ile aynı katman). Satırın `_actions` dizisi web gibi eylemi ezer, `isHidden` gizler, `isDisabled` kilitler.',
999
1002
  'Sayfalama (`isPaginated`): ‹ `1–10 / 57` › çubuğu (`<elementId>__page-prev`, `__page-next`); metin dil bağımsızdır. Web`in sayfa numarası kutusu ve sayfa başına satır seçimi yok (`limit` panelden ya da akıştan; düğümde hiç yazılmamışsa web gibi 10). Başlık doluyken yanındaki yenile düğmesi (`<elementId>__refresh`) `mount`u koşturur; `showRefresh: false` düğmeyi kaldırır.',
1000
- 'Düşenler: `style`, `actionsPinned`, `advancedFilter`, `filterPosition`, `showFilterSummary`, `showExport`, `showColumnManager`, `toolsPosition`, `footerPosition`, `stickyHeader`, `stickyFooter`, `paginationPosition`, `addRowShortcut`, `canReorderRows`, `orderField`, `revealEntity`, `rowDoubleClick`, `cellSelectionChange`; kolonların `relationTypeField` ve `relationTargets` alanı. Gelişmiş filtre oluşturucu, dışa aktarma, kolon menüsü, sürükle-sırala ve korumalı alanı gösterme telefonda yok; uzun veri için yol sayfalama ya da sonsuz kaydırmadır (satır sanallaştırma yok).',
1003
+ 'Düşenler: `style`, `actionsPinned`, `advancedFilter`, `filterPosition`, `showFilterSummary`, `showExport`, `showColumnManager`, `toolsPosition`, `footerPosition`, `stickyHeader`, `stickyFooter`, `paginationPosition`, `addRowShortcut`, `canReorderRows`, `orderField`, `revealEntity`, `rowDoubleClick`, `cellSelectionChange`; kolonların `relationTypeField` ve `relationTargets` alanı. Gelişmiş filtre oluşturucu, dışa aktarma, kolon menüsü, sürükle-sırala ve korumalı alanı gösterme telefonda yok (sıralanabilir liste: `repeater` `reorderable`); uzun veri için yol sayfalama ya da sonsuz kaydırmadır (satır sanallaştırma yok).',
1001
1004
  'Telefonda filtre (plan mobil-tasarimci-kabuk-ekranlari 34): `isFilterable` kolonlar (bağlı tabloda sunucunun kabiliyeti son sözü söyler) üstte çip şerididir (`<elementId>__chip-<kolon>`, başta rozetli `__filters`: etkin çip sayısı, ✕ hepsini temizler). Çipe dokunmak alt sayfa açar: metin kolonunda işleç (sunucunun açtıkları) + değer, seçenekli kolonda liste (`in` açıksa çoklu), anahtarda Evet/Hayır, ilişki kolonunda seçim bileşenlerinin lookup alt sayfası. Uygula `filters`a kök `and` tekil kural yazar (web filtre çubuğuyla aynı sözleşme) ve `offset: 0` ile `mount` koşturur; etkin çip mavi. Bağlı tabloda çipler bağlamanın `behavior.mobile.filterChips`ıyla açılır; `filterMode: none` her durumda kapatır.',
1002
1005
  'Telefonda düzenleme (34): bağlantının yazdığı `editMode`/`canEditRow`/`canAddRow`/`canDeleteRow`/`bulkActions` okunur. Satıra dokunmak `rowClick`i yayar (`selectedRow` sessiz yazılır) ve düzenlenebilir kolon varsa "Satırı düzenle" alt sayfasını açar (alan türüne göre denetim: metin/sayı/tarih girişi, anahtar, seçenek çipleri, ilişkide lookup). `canAddRow` + `addRowButton` ≠ `none` "Yeni satır" düğmesi (`__add-row`). `bulkActions` açıksa uzun basış seçim kipidir (`rowSelectionChange`, `selectedRows`): çubukta Toplu düzenle (tek alan, bütün seçililere) ve Sil. Bağlamanın `behavior.mobile.swipeDelete`ı kartı kaydırınca Sil (onaylı) açar. Kayıt `save` olayıyla gider (changeset + `operation`, web f-22 ile aynı); `row`/`cell` kipi anında, `batch` alt çubuktaki Kaydet ile (`discard` Vazgeç). Kısmi başarıda hatalı satır kırmızı çerçeve + satır hatası, özet şeridi "2 satır yazıldı, 1 düşmedi" (`__save-outcome`); düşen satırlar seçili kalır.',
1003
1006
  'Kart alanları ve sayfa (34): `variant: cards` iken kart bağlamanın `behavior.mobileCard`ından çizilir (başlık, sıralı etiket-değer alanları, sağ değer); eşleme yoksa ilk görsel/ilk metin kuralı. `paginationMode: infinite` ya da bağlamanın `behavior.mobile.infiniteScroll`ı (yazılmamışsa açık) sayfaları birleştirir: sayfa kaydırıcısı sona yaklaşınca sonraki `offset` kendiliğinden istenir, yeni sayfa sona eklenir, altta "Yükleniyor…"; sayfa çubuğu çizilmez (sayfa kaydırıcısı olmayan kapta "Daha fazla" `__load-more`). `behavior.mobile.pullToRefresh` sayfayı aşağı çekince listeyi yeniler (web önizlemede çekme yok: başlıkta yenile düğmesi). `showRefresh: false` yenile düğmesini kaldırır. `showRecordSummary: false` kayıt aralığı metnini kaldırır. Bunların ve yukarıdaki çip/düzenleme davranışlarının hepsi yalnız `variant: cards` kipindedir: `variant: table` salt okunur ızgaradır (dokunuş, düzenleme, seçim, çip yok).',
@@ -295,6 +295,15 @@ export const mobileComponentEventContracts = {
295
295
  count: 'number — seçili satır sayısı'
296
296
  },
297
297
  reads: 'yalnız selectionMode: multiple (ya da longPressSelects ile açılınca); akış event.data.selectedIndices / event.data.count okur, düğümün selectedIndices/selectedItems değeri de yazılır'
298
+ },
299
+ reorder: {
300
+ payload: {
301
+ fromIndex: 'number — satırın bırakılmadan önceki sırası (ekrandaki, 0 tabanlı)',
302
+ toIndex: 'number — bırakıldığı sıra',
303
+ row: 'object — taşınan satırın verisi',
304
+ patches: "Array<{ id, value }> — sırası değişen satırlar: id satırın _id'si (yoksa id'si), value orderField'a yazılacak yeni 1 tabanlı adım; eş değerli satırlar tek adımdır (aynı value), iki eş satırın arasına bırakılan satır o adıma katılır. Yalnız değeri değişenler gelir"
305
+ },
306
+ reads: "self'e yazılmaz — event.data.patches ile oku. Yalnız reorderable açık, dikey ve tek sütunlu listede; satır kendi yerine bırakılırsa olay yayılmaz. orderField yoksa ya da bir satırda _id/id yoksa patches BOŞ dizidir (sıra yalnız ekranda tutulur). Seçim varsa düğümün selectedIndices/selectedItems/selectedIndex değeri yeni sıraya taşınarak yazılır"
298
307
  }
299
308
  }
300
309
  };
@@ -82,6 +82,20 @@ export const SCREENSHOT_CAPTURE_DEVICES = {
82
82
  tablet7: { width: 600, height: 960, scale: 2 },
83
83
  tablet10: { width: 800, height: 1280, scale: 2 }
84
84
  };
85
+ /**
86
+ * Pazarlama karesinin cihaz çerçevesi (`mobile_asset_preview`, ADR 0004). Tuval mağaza ölçüsüdür
87
+ * (`SCREENSHOT_CAPTURE_DEVICES` × scale), çerçeve CSS ile çizilir: Apple'ın cihaz görselleri yeniden
88
+ * dağıtılamaz. Cihaz sınıfları arasındaki fark yalnız bu satırlardır, kodda dallanma yoktur.
89
+ */
90
+ export const STORE_FRAME_LAYOUTS = {
91
+ // Gerçek ekran köşesi ~0.125'tir; runtime karesinde alt güvenli alan olmadığı için o köşe sekme çubuğu
92
+ // etiketlerini kırpıyordu (2026-10-26, mobiltest `ana`) — köşe biraz daraltıldı.
93
+ iphone69: { deviceWidth: 0.78, bezel: 0.03, radius: 0.12, cutout: 'island' },
94
+ ipad13: { deviceWidth: 0.8, bezel: 0.03, radius: 0.05, cutout: 'none' },
95
+ phone: { deviceWidth: 0.76, bezel: 0.028, radius: 0.11, cutout: 'punch' },
96
+ tablet7: { deviceWidth: 0.8, bezel: 0.035, radius: 0.055, cutout: 'none' },
97
+ tablet10: { deviceWidth: 0.8, bezel: 0.03, radius: 0.05, cutout: 'none' }
98
+ };
85
99
  /** Beyan anahtarları (`MobileStoreSettings.*Declaration`); iOS appPrivacy, ötekiler Android */
86
100
  export const DECLARATION_KEYS = ['appPrivacy', 'dataSafety', 'contentRating', 'targetAudience', 'appAccess'];
87
101
  /**
@@ -67,6 +67,8 @@ const TEMPLATE_VALUE = /\{\{[\s\S]*\}\}/;
67
67
  * düşer (koşu 37 K37-c, `var(--ng-surface)`). Web profili bu kuralı koşmaz.
68
68
  */
69
69
  const STYLE_GROUP_PROPS = new Set(['surfaceStyle', 'typographyStyle', 'headerStyle']);
70
+ // MOB-035'in kararını veren liste prop'ları: birine tek prop yazımı kayıtlı ağacı okutur.
71
+ const REORDER_PROPS = new Set(['reorderable', 'direction', 'columnCount']);
70
72
  const UNRESOLVED_TOKEN = /var\(\s*(--ng-[\w-]+)\s*\)/g;
71
73
  let themeVarNames;
72
74
  function nearestThemeVar(name) {
@@ -115,6 +117,8 @@ export function profileFindings(profile, input) {
115
117
  const namelessIconButtons = [];
116
118
  const unknownTokens = [];
117
119
  const motionSkips = [];
120
+ const flatReorder = [];
121
+ const repeaterProps = new Map();
118
122
  const hit = (map, key, target) => map.set(key, [...(map.get(key) ?? []), target]);
119
123
  const inspectTokens = (target, value) => {
120
124
  for (const name of unknownThemeTokens(value)) {
@@ -205,6 +209,8 @@ export function profileFindings(profile, input) {
205
209
  inspectMotion(target, props.motionStyle);
206
210
  for (const group of STYLE_GROUP_PROPS)
207
211
  inspectTokens(target, props[group]);
212
+ if (type === 'repeater')
213
+ repeaterProps.set(target, props);
208
214
  if (type === 'image' && !imageHasDefiniteHeight(asRecord(node.appearance)))
209
215
  floorImages.push(target);
210
216
  if (type === 'button' && propValue(props.size) === 'icon' && !String(propValue(props.ariaLabel) ?? '').trim() && !String(propValue(props.text) ?? '').trim()) {
@@ -230,7 +236,12 @@ export function profileFindings(profile, input) {
230
236
  };
231
237
  walk(input.nodes, inspectNode);
232
238
  const savedTypes = new Map();
233
- walk(input.savedNodes, node => savedTypes.set(label(node), String(node.type ?? '')));
239
+ const savedRepeaterProps = new Map();
240
+ walk(input.savedNodes, node => {
241
+ savedTypes.set(label(node), String(node.type ?? ''));
242
+ if (node.type === 'repeater')
243
+ savedRepeaterProps.set(label(node), asRecord(node.props) ?? {});
244
+ });
234
245
  // Toplu yamanın zarfı açılır: içindeki op'lar tek tek yazılmış gibi denetlenir (TOOL-057).
235
246
  const ops = flattenBatchOps(input.ops ?? []);
236
247
  for (const op of ops) {
@@ -258,6 +269,9 @@ export function profileFindings(profile, input) {
258
269
  inspectMotion(target, op.value);
259
270
  if (STYLE_GROUP_PROPS.has(prop))
260
271
  inspectTokens(target, op.value);
272
+ const listProps = REORDER_PROPS.has(prop) ? repeaterProps.get(target) ?? savedRepeaterProps.get(target) : undefined;
273
+ if (listProps)
274
+ repeaterProps.set(target, { ...listProps, [prop]: op.value });
261
275
  // MOB-011: var olan düğümün tipi op'ta yok — tip biliniyorsa (aynı yazımda eklendi) ona, yoksa prop adına bakılır.
262
276
  const knownType = nodeTypes.get(target);
263
277
  const imageProp = knownType === undefined ? IMAGE_SOURCE_PROP_NAMES.has(prop) : (IMAGE_SOURCE_PROPS[knownType] ?? []).includes(prop);
@@ -266,6 +280,15 @@ export function profileFindings(profile, input) {
266
280
  fileIdSources.push({ target: `${target}.${prop}`, expression });
267
281
  }
268
282
  }
283
+ // MOB-035: sıralama yalnız dikey, tek sütunlu listededir (frontend `reorderEnabled`); yatay şeritte ve ızgarada
284
+ // `reorderable` yazım kabul edilir ama tutamaç çizilmez — sessiz başarı. Denetlenen, yazımdan SONRAKİ prop'lardır:
285
+ // eklenen düğümün kendi prop'ları, var olan düğüme tek prop yazımında kayıtlı prop'larla birleşimi. Dokunulmayan
286
+ // kayıtlı liste denetlenmez. Sütun sayısı çizerinki gibi tam sayıya kesilir (`repeaterColumnCount`: 1.5 tek sütundur).
287
+ for (const [target, props] of repeaterProps) {
288
+ const columns = Math.trunc(Number(propValue(props.columnCount)));
289
+ if (propValue(props.reorderable) === true && (propValue(props.direction) === 'horizontal' || (Number.isFinite(columns) && columns > 1)))
290
+ flatReorder.push(target);
291
+ }
269
292
  if (blockNodes.length > 0 || gridNodes.length > 0) {
270
293
  findings.push({
271
294
  code: MOBILE_RULES['MOB-001'].id,
@@ -302,6 +325,15 @@ export function profileFindings(profile, input) {
302
325
  guide: "platform_guide('page') §Kural 1g-3"
303
326
  });
304
327
  }
328
+ if (flatReorder.length > 0) {
329
+ findings.push({
330
+ code: MOBILE_RULES['MOB-035'].id,
331
+ severity: 'warn',
332
+ target: flatReorder.join(', '),
333
+ message: `reorderable yalnız dikey, tek sütunlu listede çalışır — yatay şeritte (direction: horizontal) ve çok sütunda (columnCount > 1) tutamaç çizilmez, satırlar sürüklenmez. Sıralama gerekiyorsa listeyi dikey ve tek sütun yapın. ${NOT_BLOCKING}`,
334
+ guide: "platform_guide('page') §Kural 1g-5"
335
+ });
336
+ }
305
337
  if (motionSkips.length > 0) {
306
338
  findings.push({
307
339
  code: MOBILE_RULES['MOB-028'].id,
@@ -483,12 +515,13 @@ function walkSteps(value, visit) {
483
515
  }
484
516
  /**
485
517
  * Denetim bu yama için kayıtlı ağacı ister mi (`ProfileLintInput.savedNodes`): `style` yazımında hedefin tipi (belge
486
- * CSS'i taşıyan düğüm mü — MOB-002), `heroTag` yazımında sayfanın öteki etiketleri (MOB-030). Çağıran ağacı yalnız bu
518
+ * CSS'i taşıyan düğüm mü — MOB-002), `heroTag` yazımında sayfanın öteki etiketleri (MOB-030), sıralama prop'u yazımında
519
+ * listenin öteki prop'ları (MOB-035). Çağıran ağacı yalnız bu
487
520
  * durumda okur. Denetimle AYNI op listesine bakar (zarf açılır): 2026-10-10'da denetim zarfı açıp çağıran açmayınca
488
521
  * kayıtlı `html` düğümünün belge CSS'i zarf içinde yanlış MOB-002 aldı (regresyon `test/batch-profile-lint.test.ts`).
489
522
  */
490
523
  export function profileLintReadsSavedNodes(ops) {
491
- return writesHeroTag(ops) || flattenBatchOps(ops).some(op => (op.op === 'replace_prop' || op.op === 'set_binding') && op.prop === 'style');
524
+ return writesHeroTag(ops) || flattenBatchOps(ops).some(op => (op.op === 'replace_prop' || op.op === 'set_binding') && (op.prop === 'style' || REORDER_PROPS.has(String(op.prop))));
492
525
  }
493
526
  /** Bulguları `layoutWarnings[]` satırlarına çevirir. */
494
527
  export function findingLines(findings) {
@@ -133,6 +133,12 @@ export const MOBILE_RULES = {
133
133
  sourceRef: 'zeyrek-frontend/packages/ng-apps-ui-native/src/motion/hero-tag.ts (kural; ayna heroTag.ts); zeyrek-frontend/docs/adr/0022-hero-yolu.md; simülatör ölçümü 2026-10-10',
134
134
  summary: "Aynı sayfada aynı statik heroTag'i birden çok düğüm taşıyor — sayfa yakınlaşmanın hedefi olduğunda ilk çizilen hizalanır; kaynak sayfada sorun değildir"
135
135
  },
136
+ 'MOB-035': {
137
+ id: 'MOB-035',
138
+ severity: 'warn',
139
+ sourceRef: 'zeyrek-frontend/packages/ng-apps-ui-native/src/components/repeater/repeater-reorder-logic.ts (reorderEnabled; plans/mobil-jest-katmani faz 02)',
140
+ summary: 'Sıralanabilir liste (`reorderable`) yatay şeritte ya da çok sütunda — çizer tutamaç çizmez, sürükleme yok'
141
+ },
136
142
  'MOB-033': {
137
143
  id: 'MOB-033',
138
144
  severity: 'warn',
@@ -1,5 +1,17 @@
1
1
  import { INTERNAL_ROLE_SEPARATOR } from '../../knowledge/componentCatalog.js';
2
2
  import { studioWebTarget } from './targets/studio-web.js';
3
+ /**
4
+ * `swipe` jestinin adım sayısı ve aralığı: react-native-web yanıtlayıcısı tek seferde atılan hareketi jest saymaz
5
+ * (`ui-native.md` §flip-card ve swipeable-stack); 12 × 16 ms oyun alanında tutamaç sıralamasını sürdü (g-02, 2026-10-27).
6
+ */
7
+ export const SWIPE_STEPS = 12;
8
+ export const SWIPE_INTERVAL_MS = 16;
9
+ /**
10
+ * `to` verilen jest hedefin içinde, hareket yönündeki kenarına bu kadar (CSS pikseli) kala biter. Merkezde bitseydi
11
+ * sürüklenen satırın merkezi hedefinkine EŞİT olurdu; çizer kardeşi kesin `<` ile saydığından (`reorderTarget`) aşağı
12
+ * taşıma bir satır eksik inerdi (inceleme bulgusu 2, oyun alanında `row-0 → row-2` → `reorder 0→1`).
13
+ */
14
+ export const SWIPE_EDGE_INSET = 2;
3
15
  /**
4
16
  * G-24 — gateway sayfayı kurabiliyor, okuyabiliyor ve çizdirebiliyordu ama üzerinde OLAY
5
17
  * TETİKLEYEMİYORDU: "filtre değişince veri yenileniyor" iddiası yalnız şema okumasıyla
@@ -273,6 +285,61 @@ export function actionScript(action, captureTarget = studioWebTarget) {
273
285
  return { found: true, applied: true, requests: count };
274
286
  })()`;
275
287
  }
288
+ // g-02 (plans/mobil-jest-katmani) — mobil jestler (tutamaçtan sıralama, satır kaydırma, deste, kaydırıcı) işaretçiyi
289
+ // dinler; HTML5 `drag` onları süremez. Gerçek tarayıcı gibi her anda pointer + mouse birlikte atılır: oyun alanında
290
+ // yalnız `pointer*` react-native-web yanıtlayıcısını SÜRMEDİ, `mouse*` sürdü (ölçüldü 2026-10-27). Hareket olayı
291
+ // parmağın altındaki öğeye gider; basış kaynağa.
292
+ if (action.action === 'swipe') {
293
+ const destination = action.to === undefined ? 'null' : JSON.stringify(`[data-element-id="${action.to}"]`);
294
+ const webNote = captureTarget.name === 'studio-mobile'
295
+ ? ''
296
+ : "swipe dokunmatik jesttir: web sayfasında yalnız işaretçiyi dinleyen öğeyi sürer — HTML5 sürükle-bırak (kanban, takvim) için 'drag'";
297
+ return `(function () {
298
+ var count = performance.getEntriesByType('resource').length;
299
+ var node = document.querySelector(${selector});
300
+ if (!node) return { found: false, applied: false, requests: count };
301
+ var center = function (element) { var rect = element.getBoundingClientRect(); return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; };
302
+ var from = center(node);
303
+ var to;
304
+ var destination = ${destination};
305
+ if (destination !== null) {
306
+ var goal = document.querySelector(destination);
307
+ if (!goal) return { found: false, applied: false, note: "jest kaynağı bulundu ama bitiş hedefi ('to') sayfada yok: " + ${JSON.stringify(String(action.to ?? ''))} + " — satır <elementId>__row-<i>, tutamaç <elementId>__row-<i>__handle", requests: count };
308
+ var box = goal.getBoundingClientRect();
309
+ var middle = center(goal);
310
+ var edge = function (start, mid, low, high) { return mid > start ? high - ${SWIPE_EDGE_INSET} : mid < start ? low + ${SWIPE_EDGE_INSET} : mid; };
311
+ to = { x: edge(from.x, middle.x, box.left, box.left + box.width), y: edge(from.y, middle.y, box.top, box.top + box.height) };
312
+ } else {
313
+ to = { x: from.x + ${Number(action.dx ?? 0)}, y: from.y + ${Number(action.dy ?? 0)} };
314
+ }
315
+ var fire = function (type, at, element) {
316
+ var init = { bubbles: true, cancelable: true, composed: true, clientX: at.x, clientY: at.y, screenX: at.x, screenY: at.y, button: 0, buttons: /up$/.test(type) ? 0 : 1, pointerId: 1, pointerType: 'mouse', isPrimary: true };
317
+ element.dispatchEvent(type.indexOf('pointer') === 0 ? new PointerEvent(type, init) : new MouseEvent(type, init));
318
+ };
319
+ var under = function (at) { return document.elementFromPoint(at.x, at.y) || node; };
320
+ var finish = function (result) { if (${JSON.stringify(webNote)}) result.note = ${JSON.stringify(webNote)}; return result; };
321
+ return new Promise(function (resolve) {
322
+ fire('pointerdown', from, node);
323
+ fire('mousedown', from, node);
324
+ var step = 0;
325
+ var move = function () {
326
+ step += 1;
327
+ var at = { x: from.x + ((to.x - from.x) * step) / ${SWIPE_STEPS}, y: from.y + ((to.y - from.y) * step) / ${SWIPE_STEPS} };
328
+ var element = under(at);
329
+ fire('pointermove', at, element);
330
+ fire('mousemove', at, element);
331
+ if (step < ${SWIPE_STEPS}) { setTimeout(move, ${SWIPE_INTERVAL_MS}); return; }
332
+ setTimeout(function () {
333
+ var end = under(to);
334
+ fire('pointerup', to, end);
335
+ fire('mouseup', to, end);
336
+ resolve(finish({ found: true, applied: true, requests: count }));
337
+ }, ${SWIPE_INTERVAL_MS});
338
+ };
339
+ setTimeout(move, ${SWIPE_INTERVAL_MS});
340
+ });
341
+ })()`;
342
+ }
276
343
  const target = String(action.target ?? '');
277
344
  const root = rootElementId(target);
278
345
  // G-14 — `applied` yalnız "DOM'a yazıldı" demekti: tarih seçici olayı dinlemiyordu, kutu dolu
@@ -188,6 +188,31 @@ async function resolveClip(page, clip) {
188
188
  }
189
189
  return { x: Math.round(raw.x), y: Math.round(raw.y), width: Math.round(raw.width), height: Math.round(raw.height) };
190
190
  }
191
+ /**
192
+ * Gateway'in varsayılan tarayıcısı: kalıcı profilli Chromium. `mobile_asset_preview` de bunu kullanır
193
+ * (ADR 0004); aynı süreçte ikinci açılış profil dizini dolu olduğu için sıradaki yuvaya geçer.
194
+ */
195
+ export async function launchScreenshotBrowser(config) {
196
+ let chromium;
197
+ try {
198
+ chromium = (await import('playwright-core')).chromium;
199
+ }
200
+ catch {
201
+ throw new PlatformApiError('playwright-core paketi yüklü değil — zeyrek-mcp dizininde `npm install playwright-core` çalıştırın, ardından tarayıcı için `npx playwright-core install chromium`.');
202
+ }
203
+ logStep('tarayıcı →', `chromium başlatılıyor (ilk çağrı — sonraki çağrılar önbellekteki tarayıcıyı kullanır) · profil ${screenshotProfileRoot()}`);
204
+ const open = persistentContextOpener(chromium, config.screenshotBrowserPath ? { executablePath: config.screenshotBrowserPath } : {});
205
+ const browser = new PersistentScreenshotBrowser(open);
206
+ try {
207
+ // İlk bağlam burada açılır ki başlatma hatası eskisi gibi `launch()`'tan dönsün.
208
+ await (await browser.newContext({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1 })).close();
209
+ }
210
+ catch (firstError) {
211
+ const message = firstError instanceof Error ? firstError.message : String(firstError);
212
+ throw new PlatformApiError(`Chromium başlatılamadı: ${message}. \`npx playwright-core install chromium\` ile indirin; alternatif olarak sistemdeki tarayıcının yolunu SCREENSHOT_BROWSER_PATH ile verin (alpine imajında \`apk add chromium\` + /usr/bin/chromium-browser).`);
213
+ }
214
+ return browser;
215
+ }
191
216
  /**
192
217
  * Playwright bağlamı ve tarayıcısı bu sınıfta PAYLAŞILIR. İki çekim aynı anda koştuğunda
193
218
  * 2026-09-06 koşusunda ikisi de `Target page, context or browser has been closed` ile düştü
@@ -272,27 +297,7 @@ export class StudioScreenshotter {
272
297
  }
273
298
  }
274
299
  async launch() {
275
- if (this.launcher)
276
- return this.launcher();
277
- let chromium;
278
- try {
279
- chromium = (await import('playwright-core')).chromium;
280
- }
281
- catch {
282
- throw new PlatformApiError('playwright-core paketi yüklü değil — zeyrek-mcp dizininde `npm install playwright-core` çalıştırın, ardından tarayıcı için `npx playwright-core install chromium`.');
283
- }
284
- logStep('tarayıcı →', `chromium başlatılıyor (ilk çağrı — sonraki çağrılar önbellekteki tarayıcıyı kullanır) · profil ${screenshotProfileRoot()}`);
285
- const open = persistentContextOpener(chromium, this.config.screenshotBrowserPath ? { executablePath: this.config.screenshotBrowserPath } : {});
286
- const browser = new PersistentScreenshotBrowser(open);
287
- try {
288
- // İlk bağlam burada açılır ki başlatma hatası eskisi gibi `launch()`'tan dönsün.
289
- await (await browser.newContext({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1 })).close();
290
- }
291
- catch (firstError) {
292
- const message = firstError instanceof Error ? firstError.message : String(firstError);
293
- throw new PlatformApiError(`Chromium başlatılamadı: ${message}. \`npx playwright-core install chromium\` ile indirin; alternatif olarak sistemdeki tarayıcının yolunu SCREENSHOT_BROWSER_PATH ile verin (alpine imajında \`apk add chromium\` + /usr/bin/chromium-browser).`);
294
- }
295
- return browser;
300
+ return this.launcher ? this.launcher() : launchScreenshotBrowser(this.config);
296
301
  }
297
302
  /**
298
303
  * Bekleme stratejisi bilinçli olarak `networkidle` DEĞİL: Studio'nun kök layout'u
@@ -0,0 +1,114 @@
1
+ import { mkdir, rm, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { PlatformApiError } from '../errors.js';
5
+ import { imageSize } from '../capture/capture.js';
6
+ import { STORE_ART_LAYOUTS, storeCanvas } from './layouts.js';
7
+ import { BACKDROPS, FONT_STACKS, resolveStyle } from './styles.js';
8
+ import { cardFileName } from './deck.js';
9
+ /**
10
+ * Mağaza pazarlama karesi (ADR 0004, 0005): ham ekran görüntüsü yerleşimin kutularına oturtulur, stilin
11
+ * zemini, yazısı ve sahnesiyle mağazanın istediği ölçüde alfasız JPEG çizilir. Çizim gateway'in Chromium'uyla
12
+ * yapılır; kare HTML'i saf fonksiyondur ve yerleşime ya da stile göre dallanmaz — ikisi de kayıttan okunur.
13
+ * Editörün önizlemesi de aynı HTML'dir (`cardHtml`); önizleme ile çıktı ayrışamaz.
14
+ */
15
+ export { storeCanvas } from './layouts.js';
16
+ const jpegQuality = 92;
17
+ const renderTimeoutMs = 30000;
18
+ /**
19
+ * Görseller `decode()` ile beklenir: `domcontentloaded` anında `<img>` henüz çözülmemiş olabilir ve kare
20
+ * boş ekranla çıkardı. Yazı tipi de beklenir; satır kırılımı yüklenen yazı tipine göre değişir.
21
+ */
22
+ const slideReadyScript = "Promise.all([document.fonts.ready, ...Array.from(document.images).map(image => image.decode())]).then(() => true, error => String(error && error.message ? error.message : error))";
23
+ export function slideGeometry(deviceClass, frame, captionPosition, layout = 'classic') {
24
+ return STORE_ART_LAYOUTS[layout].geometry({ deviceClass, frame, captionPosition });
25
+ }
26
+ const escapeHtml = (value) => value.replace(/[&<>"']/g, character => `&#${character.charCodeAt(0)};`);
27
+ const place = (box) => `left:${box.x}px;top:${box.y}px;width:${box.width}px;height:${box.height}px`;
28
+ const clamp = (lines) => `display:-webkit-box;-webkit-line-clamp:${lines};-webkit-box-orient:vertical;overflow:hidden`;
29
+ /**
30
+ * `locale` sayfanın `lang`'idir: CSS `text-transform: uppercase` dilin kuralını ondan alır — dil yokken Türkçe
31
+ * "bir" "BIR" çiziliyordu, "BİR" olmalı (2026-10-26, swiss stili).
32
+ */
33
+ export function slideHtml(geometry, style, slide, imageUrl, locale = 'en') {
34
+ const { canvas, caption, device, screen, cutout } = geometry;
35
+ const phone = device ?? screen;
36
+ const shadow = `0 ${Math.round(canvas.height * 0.012)}px ${Math.round(canvas.height * 0.03)}px rgba(0,0,0,${style.shadow})`;
37
+ const glow = style.glow ? `,0 0 ${Math.round(canvas.width * 0.09)}px ${style.colors.accent}88` : '';
38
+ const showScreen = screen !== undefined && imageUrl !== undefined;
39
+ return [
40
+ `<!doctype html><html lang="${escapeHtml(locale)}"><head><meta charset="utf-8"><style>`,
41
+ '*{margin:0;padding:0;box-sizing:border-box}',
42
+ `html,body{width:${canvas.width}px;height:${canvas.height}px;overflow:hidden}`,
43
+ `body{position:relative;background:${BACKDROPS[style.backdrop](style.colors, canvas)};color:${style.colors.text};font-family:${FONT_STACKS[style.font]};-webkit-font-smoothing:antialiased}`,
44
+ `.caption{position:absolute;${place(caption)};display:flex;flex-direction:column;justify-content:center;align-items:${style.align === 'left' ? 'flex-start' : 'center'};gap:${Math.round(geometry.subtitleSize * 0.5)}px;text-align:${style.align}}`,
45
+ `.eyebrow{font-size:${geometry.eyebrowSize}px;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;color:${style.colors.accent}}`,
46
+ `.title{font-size:${geometry.titleSize}px;font-weight:${style.titleWeight};line-height:1.1;letter-spacing:-0.01em;text-transform:${style.titleCase};${clamp(geometry.titleLines)}}`,
47
+ `.subtitle{font-size:${geometry.subtitleSize}px;font-weight:500;line-height:1.25;opacity:0.8;${clamp(2)}}`,
48
+ phone ? `.phone{position:absolute;inset:0;transform-origin:${phone.x + phone.width / 2}px ${phone.y + phone.height / 2}px;transform:${geometry.phoneTransform ?? 'none'}}` : '',
49
+ device ? `.device{position:absolute;${place(device)};border-radius:${device.radius}px;background:${style.frameColor};box-shadow:${shadow}${glow},inset 0 0 0 ${Math.max(Math.round(device.width * 0.004), 2)}px #3a3a3e}` : '',
50
+ screen ? `.screen{position:absolute;${place(screen)};border-radius:${screen.radius}px;overflow:hidden;background:#ffffff${device ? '' : `;box-shadow:${shadow}${glow}`}}` : '',
51
+ '.screen img{display:block;width:100%;height:100%;object-fit:cover;object-position:top}',
52
+ cutout ? `.cutout{position:absolute;${place(cutout)};border-radius:${cutout.radius}px;background:#000000}` : '',
53
+ '</style></head><body>',
54
+ '<div class="caption">',
55
+ slide.eyebrow ? `<div class="eyebrow">${escapeHtml(slide.eyebrow)}</div>` : '',
56
+ `<div class="title">${escapeHtml(slide.title)}</div>`,
57
+ slide.subtitle ? `<div class="subtitle">${escapeHtml(slide.subtitle)}</div>` : '',
58
+ '</div>',
59
+ showScreen ? '<div class="phone">' : '',
60
+ showScreen && device ? '<div class="device"></div>' : '',
61
+ showScreen ? `<div class="screen"><img src="${escapeHtml(imageUrl)}" alt=""></div>` : '',
62
+ showScreen && cutout ? '<div class="cutout"></div>' : '',
63
+ showScreen ? '</div>' : '',
64
+ '</body></html>'
65
+ ].join('');
66
+ }
67
+ /** Kartın HTML'i; `imageUrl` ham karenin adresidir (çizimde file://, editör önizlemesinde galeri adresi) */
68
+ export function cardHtml(deck, spec, imageUrl) {
69
+ const raw = spec.raw === undefined || !STORE_ART_LAYOUTS[spec.layout].needsScreen ? undefined : deck.raws[spec.raw];
70
+ return slideHtml(slideGeometry(deck.deviceClass, spec.frame, spec.captionPosition, spec.layout), resolveStyle(spec.style, { primary: spec.accent, text: spec.text }), spec, raw ? imageUrl(raw) : undefined, deck.locale);
71
+ }
72
+ /**
73
+ * Kartları tek tarayıcıda sırayla çizer; her kart `fileName(card)` adıyla galeri dizinine yazılır. Editör geçici
74
+ * ad verir ve dosyaları ancak bütün iş başarılı olunca yerine taşır (yarıda düşen çizim kartı ezmesin).
75
+ */
76
+ export async function renderCards(launch, deck, directory, cards, fileName = cardFileName) {
77
+ const canvas = storeCanvas(deck.deviceClass);
78
+ const renderDirectory = path.join(directory, '.render');
79
+ let browser;
80
+ try {
81
+ await mkdir(renderDirectory, { recursive: true });
82
+ browser = await launch();
83
+ const context = await browser.newContext({ viewport: canvas, deviceScaleFactor: 1 });
84
+ try {
85
+ const page = await context.newPage();
86
+ const files = [];
87
+ for (const card of cards) {
88
+ const htmlPath = path.join(renderDirectory, `${card.name}.html`);
89
+ await writeFile(htmlPath, cardHtml(deck, card.spec, raw => pathToFileURL(raw.path).href));
90
+ await page.goto(pathToFileURL(htmlPath).href, { waitUntil: 'domcontentloaded', timeout: renderTimeoutMs });
91
+ const ready = await page.evaluate(slideReadyScript);
92
+ if (ready !== true) {
93
+ throw new PlatformApiError(`mobile_asset_preview: slides[${card.slide}] çizilemedi — ham kare tarayıcıda açılmadı (${String(ready)}). Dosyanın bozuk olmadığını page_screenshot ile yeniden çekerek doğrulayın (kart ${card.name}).`, 422);
94
+ }
95
+ const bytes = await page.screenshot({ type: 'jpeg', quality: jpegQuality, fullPage: false });
96
+ const size = imageSize(bytes);
97
+ if (size === undefined || size.width !== canvas.width || size.height !== canvas.height) {
98
+ throw new PlatformApiError(`mobile_asset_preview: slides[${card.slide}] ${size ? `${size.width}×${size.height}` : 'ölçüsü okunamayan'} çizildi, ${deck.deviceClass} mağaza ölçüsü ${canvas.width}×${canvas.height}. Mağaza bu kareyi reddederdi; dosya yazılmadı (kart ${card.name}).`, 500);
99
+ }
100
+ const file = path.join(directory, fileName(card));
101
+ await writeFile(file, bytes);
102
+ files.push({ card: card.name, style: card.spec.style, index: card.slide, path: file, width: size.width, height: size.height });
103
+ }
104
+ return files;
105
+ }
106
+ finally {
107
+ await context.close().catch(() => undefined);
108
+ }
109
+ }
110
+ finally {
111
+ await browser?.close().catch(() => undefined);
112
+ await rm(renderDirectory, { recursive: true, force: true });
113
+ }
114
+ }
@@ -0,0 +1,133 @@
1
+ import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { z } from 'zod';
4
+ import { COLOR_PATTERN, SCREENSHOT_CAPTURE_DEVICES } from '../../knowledge/mobileStore.js';
5
+ import { PlatformApiError } from '../errors.js';
6
+ import { imageSize } from '../capture/capture.js';
7
+ import { LAYOUT_IDS, STORE_ART_LAYOUTS } from './layouts.js';
8
+ import { STYLE_IDS } from './styles.js';
9
+ /**
10
+ * Pazarlama görseli destesi (ADR 0004 §Ek): araçtan gelen kareler × stiller birer KART olur; galeri editörü kartın
11
+ * spec'ini değiştirip yeniden çizer. Ham kareler galeri dizinine `raw-NN.<uzantı>` adıyla kopyalanır — sunucu
12
+ * yalnız bu adları sunar, çizici de kopyayı okur. Spec'in sınırları aracın girdi sınırlarıyla aynıdır (araç bu
13
+ * sabitleri kullanır); editörden gelen spec sunucuda bu şemayla yeniden doğrulanır.
14
+ */
15
+ export const TITLE_MAX = 80;
16
+ export const SUBTITLE_MAX = 140;
17
+ export const EYEBROW_MAX = 30;
18
+ export const MAX_STYLES = 4;
19
+ export const MAX_CARDS = 40;
20
+ const defaultStyles = ['clean'];
21
+ const aspectTolerance = 0.01;
22
+ const pad = (value) => String(value).padStart(2, '0');
23
+ /** `deck.json`'a ve istemciye tanımsız alan gitmez: boş üst etiket "alan yok" demektir */
24
+ const withoutUndefined = (value) => Object.fromEntries(Object.entries(value).filter(([, field]) => field !== undefined));
25
+ /** Boş metin alanı siler: editörde üst etiketi ya da alt başlığı temizlemek alanı kaldırmaktır */
26
+ const optionalText = (max) => z.string().trim().max(max).optional().transform(value => value ? value : undefined);
27
+ const hexColor = z.string().regex(COLOR_PATTERN);
28
+ const cardSpecSchema = z.object({
29
+ raw: z.number().int().min(0).optional(),
30
+ eyebrow: optionalText(EYEBROW_MAX),
31
+ title: z.string().trim().min(1).max(TITLE_MAX),
32
+ subtitle: optionalText(SUBTITLE_MAX),
33
+ style: z.enum(STYLE_IDS),
34
+ layout: z.enum(LAYOUT_IDS),
35
+ captionPosition: z.enum(['top', 'bottom']),
36
+ accent: hexColor.optional(),
37
+ text: hexColor.optional(),
38
+ frame: z.boolean()
39
+ }).strict();
40
+ /** Editörden gelen spec: şema + destenin kendi kuralları (ham kare destede olmalı; cihaz gösteren yerleşim kare ister) */
41
+ export function parseCardSpec(deck, value) {
42
+ const parsed = cardSpecSchema.safeParse(value);
43
+ if (!parsed.success)
44
+ return { error: parsed.error.issues.map(issue => `${issue.path.join('.') || 'spec'}: ${issue.message}`).join('; ') };
45
+ const spec = withoutUndefined(parsed.data);
46
+ if (spec.raw !== undefined && spec.raw >= deck.raws.length)
47
+ return { error: `raw: destede ${deck.raws.length} ham kare var, ${spec.raw} yok` };
48
+ if (STORE_ART_LAYOUTS[spec.layout].needsScreen && spec.raw === undefined)
49
+ return { error: `layout: '${spec.layout}' ham kare ister — raw verin ya da 'text' seçin` };
50
+ return { spec };
51
+ }
52
+ async function readSlideSource(source, index) {
53
+ const absolute = path.resolve(source);
54
+ let bytes;
55
+ try {
56
+ bytes = await readFile(absolute);
57
+ }
58
+ catch (error) {
59
+ throw new PlatformApiError(`mobile_asset_preview: slides[${index}].path okunamadı — ${absolute} (${error instanceof Error ? error.message : String(error)}). Yol gateway sürecinin çalıştığı makineye göre çözülür; page_screenshot outPath'ini verin. Hiçbir kare çizilmedi.`, 400);
60
+ }
61
+ const size = imageSize(bytes);
62
+ if (size === undefined) {
63
+ throw new PlatformApiError(`mobile_asset_preview: slides[${index}].path PNG ya da JPEG değil — ${absolute}. Ham kareyi page_screenshot { format: 'jpeg', outPath } ile üretin. Hiçbir kare çizilmedi.`, 400);
64
+ }
65
+ return { absolute, size, png: bytes[0] === 0x89 };
66
+ }
67
+ /**
68
+ * Araç girdisinden desteyi kurar: ham kareleri doğrular ve galeri dizinine kopyalar, kartları kare sırasıyla
69
+ * (her karenin stilleri yan yana) dizer — deste sırası yükleme sırasıdır.
70
+ */
71
+ export async function buildDeck(input, directory) {
72
+ const styles = input.styles ?? defaultStyles;
73
+ if (new Set(styles).size !== styles.length) {
74
+ throw new PlatformApiError(`mobile_asset_preview: styles tekrar ediyor (${styles.join(', ')}) — her stil bir kez verilir. Hiçbir kare çizilmedi.`, 400);
75
+ }
76
+ const capture = SCREENSHOT_CAPTURE_DEVICES[input.deviceClass];
77
+ const sources = await Promise.all(input.slides.map(async (slide, index) => {
78
+ const layout = slide.layout ?? 'classic';
79
+ if (!STORE_ART_LAYOUTS[layout].needsScreen)
80
+ return undefined;
81
+ if (slide.path === undefined) {
82
+ throw new PlatformApiError(`mobile_asset_preview: slides[${index}].path gerekli — '${layout}' yerleşimi ham kareyi cihazda gösterir; yalnız 'text' yerleşimi kare istemez. Hiçbir kare çizilmedi.`, 400);
83
+ }
84
+ return readSlideSource(slide.path, index);
85
+ }));
86
+ // Oranı farklı kare ekrana `object-fit: cover` ile kırpılarak oturur; çizim durmaz ama ajan bilmeli.
87
+ const warnings = sources.flatMap((source, index) => source && Math.abs(source.size.height / source.size.width - capture.height / capture.width) > aspectTolerance
88
+ ? [`slides[${index}]: ham kare ${source.size.width}×${source.size.height}, ${input.deviceClass} ekranı ${capture.width}×${capture.height} oranında — kare ekrana kırpılarak oturdu. page_screenshot'ı device { width: ${capture.width}, height: ${capture.height}, scale: ${capture.scale} } ile çekin.`]
89
+ : []);
90
+ const raws = [];
91
+ const rawOfSlide = [];
92
+ await mkdir(directory, { recursive: true });
93
+ for (const [index, source] of sources.entries()) {
94
+ if (source === undefined) {
95
+ rawOfSlide.push(undefined);
96
+ continue;
97
+ }
98
+ const name = `raw-${pad(index + 1)}.${source.png ? 'png' : 'jpg'}`;
99
+ const copy = path.join(directory, name);
100
+ await copyFile(source.absolute, copy);
101
+ raws.push({ name, path: copy, ...source.size });
102
+ rawOfSlide.push(raws.length - 1);
103
+ }
104
+ const cards = input.slides.flatMap((slide, index) => styles.map((style) => ({
105
+ name: `${style}-${pad(index + 1)}`,
106
+ slide: index,
107
+ version: 1,
108
+ // Araç girdisi zod'dan geçmiştir; yalnız tanımsız alanlar atılır.
109
+ spec: withoutUndefined({
110
+ raw: rawOfSlide[index],
111
+ eyebrow: slide.eyebrow,
112
+ title: slide.title,
113
+ subtitle: slide.subtitle,
114
+ style,
115
+ layout: slide.layout ?? 'classic',
116
+ captionPosition: input.captionPosition ?? 'top',
117
+ accent: input.palette?.primary,
118
+ text: input.palette?.text,
119
+ frame: input.frame ?? true
120
+ })
121
+ })));
122
+ return { deck: { deviceClass: input.deviceClass, locale: input.locale, slot: `screenshot/${input.locale}/${input.deviceClass}`, raws, cards }, warnings };
123
+ }
124
+ /** Kartın kopyası: aynı spec, kare sırası ve dosya; ad `<ad>-2`, `<ad>-3` … */
125
+ export function duplicateCard(deck, original) {
126
+ const names = new Set(deck.cards.map(card => card.name));
127
+ let suffix = 2;
128
+ while (names.has(`${original.name}-${suffix}`))
129
+ suffix += 1;
130
+ return { name: `${original.name}-${suffix}`, slide: original.slide, version: 1, spec: { ...original.spec } };
131
+ }
132
+ export const cardFileName = (card) => `${card.name}.jpg`;
133
+ export const saveDeck = (directory, deck) => writeFile(path.join(directory, 'deck.json'), JSON.stringify(deck, null, 2));