/*
<javascriptresource>
<name>Social Safe Zones...</name>
<category>social</category>
<about>Creates a social media canvas with safe-zone overlays, guides and a UI mockup.</about>
</javascriptresource>
*/

/*
 * Social Safe Zones - Photoshop script (ExtendScript, Photoshop 2020+)
 *
 * Pick one or more social media formats in a dialog; for each one the script creates:
 *   - the canvas (exact pixel size, 72 ppi, sRGB)
 *   - guides on the safe area and crop frames
 *   - low-opacity shape layers marking areas covered by the platform UI, areas that may be
 *     covered, and areas cropped in some views (profile grid, desktop/mobile, avatar circle)
 *   - an optional mockup of the platform UI (shape + text layers)
 *   - an ARTWORK group, selected, ready for your design
 *
 * Install: copy this file to  Photoshop/Presets/Scripts/  and restart Photoshop
 *          (File > Scripts > Social Safe Zones...), or run it via File > Scripts > Browse...
 *
 * Every size and zone lives in the FORMAT LIBRARY below. Platforms change their UI often:
 * values marked "~" are approximations, adjust them freely.
 */

#target photoshop

(function () {

var VERSION = '1.0.0';
var PREFS_FILE = File(Folder.userData + '/SocialSafeZones.prefs');

// =============================================================================
// 1. FORMAT LIBRARY
// =============================================================================
// Pixel values at 72 ppi.
//   safe:  [top, right, bottom, left] margins of the safe area (green frame + guides)
//   safeLabel: corner of the safe-area label, 'bl' (default) | 'br' | 'tl' | 'tr'
//   zones: overlays, built with the helpers below. Zone kinds:
//          'danger'  (red)    covered by the platform UI
//          'caution' (orange) may be covered (hover, ads, some devices)
//          'crop'    (blue)   cropped in some views
//   mockup: [builder, config] for the UI mockup (see section 5)

var APPROX = '\u2248';
var DOT = ' \u00B7 ';
var TIMES = '\u00D7';

function band(side, size, label, k, from, to) {
    return { t: 'band', side: side, size: size, label: label, k: k || 'danger', from: from, to: to };
}
function top(size, label, k, from, to) { return band('top', size, label, k, from, to); }
function bottom(size, label, k, from, to) { return band('bottom', size, label, k, from, to); }
function left(size, label, k, from, to) { return band('left', size, label, k, from, to); }
function right(size, label, k, from, to) { return band('right', size, label, k, from, to); }
function box(x, y, w, h, label, k) { return { t: 'rect', r: [x, y, w, h], label: label, k: k || 'danger' }; }
function oval(x, y, w, h, label, k) { return { t: 'ellipse', r: [x, y, w, h], label: label, k: k || 'danger' }; }
// Everything outside a circle (default: largest centred circle)
function circleCrop(label, r) { return { t: 'outside', r: r, label: label || 'Cropped (circle)', k: 'danger' }; }
// Centred crop to an aspect ratio, e.g. crop([3, 4], 'Profile grid')
function crop(ratio, label, tint, lp) { return { t: 'crop', ratio: ratio, label: label, tint: tint !== false, lp: lp }; }
// Explicit crop frame; lp = label corner: 'tl' | 'tr' | 'bl' | 'br'
function cropRect(x, y, w, h, label, tint, lp) { return { t: 'crop', r: [x, y, w, h], label: label, tint: !!tint, lp: lp }; }

// --- shared zone sets --------------------------------------------------------
var STORY_IG = [
    top(250, 'Profile, progress bar & close'),
    bottom(340, 'Reply bar, reactions & CTA'),
    left(60, 'Edge', 'caution', 250, 1580),
    right(60, 'Edge', 'caution', 250, 1580)
];
var REELS_META = [
    top(270, 'Header & camera'),
    bottom(670, 'Caption, profile, audio & ad CTA'),
    box(940, 900, 140, 350, 'Action buttons'),
    left(65, 'Edge', 'caution', 270, 1250),
    right(65, 'Edge', 'caution', 270, 900),
    crop([3, 4], 'Profile grid 3:4', false),
    crop([4, 5], 'Feed preview 4:5', false)
];
function pinZones(h) {
    return [
        box(690, 50, 260, 150, 'Save (hover)', 'caution'),
        box(50, h - 186, 520, 136, 'Link (hover)', 'caution'),
        box(670, h - 186, 280, 136, 'Share & more', 'caution')
    ];
}

// --- the library -------------------------------------------------------------
var LIB = [];
function add(platform, name, w, h, o) {
    o = o || {};
    o.platform = platform; o.name = name; o.w = w; o.h = h;
    o.zones = o.zones || [];
    o.id = (platform + '-' + name + '-' + w + 'x' + h).toLowerCase().replace(/[^a-z0-9]+/g, '-');
    LIB.push(o);
}

// ---------------------------------------------------------------- Instagram
add('Instagram', 'Story', 1080, 1920, {
    safe: [250, 60, 340, 60], zones: STORY_IG,
    mockup: ['story', { name: 'yourbrand', time: '2 h', right: ['close', 'more'], pill: 'Send message', icons: ['heart', 'send'] }],
    notes: 'Top 250 px: profile, username, progress bar. Bottom 340 px: reply field & reactions (taller for ads with a CTA). Video: up to 60 s per card, MP4 H.264.'
});
add('Instagram', 'Reel / Reel cover', 1080, 1920, {
    safe: [270, 140, 670, 65], zones: REELS_META,
    mockup: ['reel', 'instagram'],
    notes: 'Meta guidance: keep ~14% top, ~35% bottom and ~6% sides free of text & logos. Cover: the profile grid shows the centre 3:4 (1080' + TIMES + '1440), the feed preview 4:5 (1080' + TIMES + '1350).'
});
add('Instagram', 'Feed post portrait 4:5', 1080, 1350, {
    zones: [crop([3, 4], 'Profile grid 3:4'), box(956, 24, 100, 56, 'Carousel counter', 'caution'), oval(24, 1262, 64, 64, '', 'caution')],
    mockup: ['igFeed'],
    notes: 'Best performing feed ratio. Since 2025 the profile grid is 3:4: the outer 34 px on each side are hidden on the grid. Carousels: every slide uses the size of the first one.'
});
add('Instagram', 'Feed post 3:4', 1080, 1440, {
    zones: [crop([4, 5], 'Feed 4:5 fallback ' + APPROX, false), box(956, 24, 100, 56, 'Carousel counter', 'caution'), oval(24, 1352, 64, 64, '', 'caution')],
    mockup: ['igFeed'],
    notes: 'Matches the 3:4 profile grid (no crop on the grid). Where 3:4 is not supported yet, the centre 4:5 is shown.'
});
add('Instagram', 'Feed post square', 1080, 1080, {
    zones: [crop([3, 4], 'Profile grid 3:4'), box(956, 24, 100, 56, 'Carousel counter', 'caution'), oval(24, 992, 64, 64, '', 'caution')],
    mockup: ['igFeed'],
    notes: 'Square posts lose 135 px on each side on the 3:4 profile grid.'
});
add('Instagram', 'Feed post landscape', 1080, 566, {
    zones: [crop([3, 4], 'Grid 3:4'), box(956, 24, 100, 56, 'Carousel counter', 'caution')],
    mockup: ['igFeed'],
    notes: '1.91:1. Displayed small in the feed and heavily cropped on the profile grid: keep the subject in the centre.'
});
add('Instagram', 'Profile picture', 1080, 1080, {
    zones: [circleCrop()],
    notes: 'Displayed as a circle (110 px on mobile, 150 px on desktop), stored at 320' + TIMES + '320. Keep logos well inside the circle and readable at small sizes.'
});
add('Instagram', 'Highlight cover', 1080, 1920, {
    zones: [circleCrop('Outside the highlight circle', [0, 420, 1080, 1080])],
    notes: 'Highlights show the centre circle only. Keep the icon around 600 px wide, centred.'
});

// ---------------------------------------------------------------- Facebook
add('Facebook', 'Feed post square', 1080, 1080, { notes: '1:1, safe on every placement.' });
add('Facebook', 'Feed post portrait 4:5', 1080, 1350, { notes: 'Mobile feed displays up to 4:5. Desktop may show it smaller.' });
add('Facebook', 'Landscape / link', 1200, 630, { notes: '1.91:1, used for link previews and landscape posts.' });
add('Facebook', 'Story', 1080, 1920, {
    safe: [250, 60, 340, 60], zones: STORY_IG,
    mockup: ['story', { name: 'Your Page', time: '3 h', right: ['close', 'more'], pill: 'Reply\u2026', icons: ['thumb', 'heart'] }],
    notes: 'Same safe zones as Instagram Stories (Meta guidance: ~14% top, ~20% bottom).'
});
add('Facebook', 'Reel', 1080, 1920, {
    safe: [270, 140, 670, 65], zones: REELS_META.slice(0, 5),
    mockup: ['reel', 'facebook'],
    notes: 'Meta guidance for Reels: keep ~14% top, ~35% bottom and ~6% sides free of text & logos.'
});
add('Facebook', 'Cover photo', 1640, 924, {
    safe: [150, 60, 150, 60], safeLabel: 'br',
    zones: [cropRect(0, 150, 1640, 624, 'Desktop view (820' + TIMES + '312 @2x)', true), oval(64, 606, 336, 336, 'Profile photo ' + APPROX, 'caution')],
    mockup: ['avatar', { r: [64, 606, 336] }],
    notes: 'Upload 1640' + TIMES + '924 (2x). Mobile shows the full 16:9 image; desktop shows the centre 1640' + TIMES + '624 strip. Profile picture overlaps the bottom-left (' + APPROX + ').'
});
add('Facebook', 'Event cover', 1920, 1005, { notes: '1.91:1. Shown smaller in feeds and invites: keep text large.' });
add('Facebook', 'Group cover', 1640, 856, { notes: 'Displayed full width; sides can be trimmed on some mobile screens: keep content centred.' });
add('Facebook', 'Profile picture', 720, 720, { zones: [circleCrop()], notes: 'Displayed as a circle (176 px on desktop).' });

// ---------------------------------------------------------------- X / Twitter
add('X (Twitter)', 'Header', 1500, 500, {
    safe: [60, 40, 60, 40], safeLabel: 'br',
    zones: [top(60, 'May be cropped', 'crop'), bottom(60, 'May be cropped', 'crop'), oval(40, 324, 353, 353, 'Profile photo ' + APPROX)],
    mockup: ['avatar', { r: [40, 324, 353] }],
    notes: '3:1. Top & bottom can be cropped depending on screen width. The profile photo overlaps the bottom-left corner.'
});
add('X (Twitter)', 'Post landscape 16:9', 1600, 900, { notes: 'Single images display in full at 16:9 on desktop & mobile.' });
add('X (Twitter)', 'Post square', 1080, 1080, { notes: '1:1.' });
add('X (Twitter)', 'Post portrait 4:5', 1080, 1350, { notes: 'Tall images may be centre-cropped in the timeline on some clients; full image on tap.' });
add('X (Twitter)', 'Link card', 1200, 628, {
    zones: [box(24, 552, 340, 52, 'Domain label')],
    mockup: ['xCard'],
    notes: 'Summary card with large image. X overlays the domain name on the bottom-left of the image.'
});
add('X (Twitter)', 'Profile picture', 400, 400, { zones: [circleCrop()], notes: 'Circle for people, rounded square for verified organisations.' });

// ---------------------------------------------------------------- LinkedIn
add('LinkedIn', 'Profile banner (personal)', 1584, 396, {
    safe: [30, 40, 30, 540],
    zones: [oval(68, 177, 439, 439, '', 'caution'), oval(47, 175, 315, 315, 'Profile photo ' + APPROX)],
    mockup: ['avatar', { r: [47, 175, 315] }],
    notes: 'Personal profile (free & Premium). 4:1. Red = profile photo on desktop, orange = on the mobile app (' + APPROX + '). Put text & logos on the right two-thirds.'
});
add('LinkedIn', 'Company Page cover (business)', 1128, 191, {
    safe: [16, 24, 16, 200],
    zones: [box(34, 118, 146, 146, 'Logo ' + APPROX)],
    mockup: ['avatar', { r: [34, 118, 146], square: true }],
    notes: 'LinkedIn Page (company / showcase). The logo overlaps the bottom-left corner. Keep content centred-right.'
});
add('LinkedIn', 'Post landscape', 1200, 627, { notes: '1.91:1, also used for link previews.' });
add('LinkedIn', 'Post square', 1080, 1080, { notes: '1:1.' });
add('LinkedIn', 'Post portrait 4:5', 1080, 1350, { notes: '4:5, takes the most space in the mobile feed.' });
add('LinkedIn', 'Document carousel (PDF)', 1080, 1350, {
    zones: [top(80, 'Title bar (hover) ' + APPROX, 'caution'), bottom(80, 'Page counter & controls ' + APPROX, 'caution')],
    mockup: ['liDoc'],
    notes: 'Export as a multi-page PDF, one page per slide (4:5 or 1:1). LinkedIn overlays the title and page controls.'
});
add('LinkedIn', 'Vertical video', 1080, 1920, {
    safe: [200, 150, 500, 48],
    zones: [top(200, 'Header'), bottom(500, 'Author, caption & CTA ' + APPROX), box(930, 880, 150, 540, 'Action buttons ' + APPROX), left(48, 'Edge', 'caution', 200, 1420)],
    mockup: ['reel', 'linkedin'],
    notes: '9:16 vertical video feed. UI overlays are approximate.'
});
add('LinkedIn', 'Event cover', 1776, 444, { notes: '4:1.' });
add('LinkedIn', 'Article / newsletter cover', 1920, 1080, {
    zones: [crop([1.91, 1], 'Feed preview ' + APPROX + ' 1.91:1')],
    notes: '16:9. Feed previews crop it to ' + APPROX + '1.91:1.'
});
add('LinkedIn', 'Profile picture', 400, 400, { zones: [circleCrop()], notes: 'Personal profile photo, displayed as a circle.' });
add('LinkedIn', 'Company logo', 400, 400, { notes: 'LinkedIn Page logo, displayed as a square.' });

// ---------------------------------------------------------------- YouTube
add('YouTube', 'Thumbnail', 1280, 720, {
    zones: [box(1110, 628, 142, 64, 'Duration'), box(1180, 24, 76, 164, '', 'caution'), bottom(12, '', 'caution')],
    mockup: ['ytThumb'],
    notes: '16:9, max 2 MB. Bottom-right: duration badge. Top-right: hover buttons. Bottom: red progress bar once watched. Check legibility at 168' + TIMES + '94.'
});
add('YouTube', 'Channel banner', 2560, 1440, {
    safe: [508, 507, 509, 507],
    zones: [
        box(0, 0, 2560, 508, 'Visible on TV only', 'crop'), box(0, 931, 2560, 509, 'Visible on TV only', 'crop'),
        cropRect(0, 508, 2560, 423, 'Desktop max (2560' + TIMES + '423)', false, 'tl'),
        cropRect(352, 508, 1855, 423, 'Tablet (1855' + TIMES + '423)', false, 'tr')
    ],
    notes: 'TV shows the whole image. Desktop: centre strip 2560' + TIMES + '423. Text & logos: centre 1546' + TIMES + '423 (visible everywhere). Max 6 MB.'
});
add('YouTube', 'Video 16:9', 1920, 1080, {
    safe: [54, 96, 54, 96], safeLabel: 'br',
    zones: [top(100, 'Embed title & share', 'caution'), bottom(130, 'Player controls (hover)', 'caution'), cropRect(67, 38, 1786, 1004, 'Action safe 93%', false, 'tr')],
    mockup: ['ytPlayer'],
    notes: 'Green = title safe (90%). Player controls cover the bottom on hover; captions appear in the lower third.'
});
add('YouTube', 'End screen', 1920, 1080, {
    zones: [box(190, 330, 720, 405, 'Video element', 'caution'), box(1010, 330, 720, 405, 'Video element', 'caution'), oval(835, 770, 250, 250, 'Subscribe', 'caution')],
    mockup: ['ytEnd'],
    notes: 'Last 5\u201320 s of the video. Elements are placed in YouTube Studio; this is a common 2 videos + subscribe layout.'
});
add('YouTube', 'Shorts', 1080, 1920, {
    safe: [240, 160, 480, 48],
    zones: [
        top(240, 'Search, camera & menu'), bottom(480, 'Channel, title, subscribe & audio'),
        box(920, 860, 160, 580, 'Action buttons'),
        left(48, 'Edge', 'caution', 240, 1440), right(48, 'Edge', 'caution', 240, 860)
    ],
    mockup: ['reel', 'shorts'],
    notes: 'Up to 3 min. Keep text in the centre: the right rail and the bottom title area cover the video.'
});
add('YouTube', 'Profile picture', 800, 800, { zones: [circleCrop()], notes: 'Displayed as a circle down to 98' + TIMES + '98.' });
add('YouTube', 'Post image', 1080, 1080, { notes: 'Community / Posts tab image, 1:1.' });

// ---------------------------------------------------------------- TikTok
add('TikTok', 'Video', 1080, 1920, {
    safe: [160, 140, 480, 60],
    zones: [
        top(160, 'Tabs, LIVE & search'), bottom(480, 'Caption, sound & nav bar'),
        box(940, 760, 140, 680, 'Action buttons'),
        left(60, 'Edge', 'caution', 160, 1440), right(60, 'Edge', 'caution', 160, 760)
    ],
    mockup: ['reel', 'tiktok'],
    notes: 'Right rail and caption cover the video. The bottom nav bar overlays the video on 16:9 phones.'
});
add('TikTok', 'In-feed ad', 1080, 1920, {
    safe: [160, 140, 600, 60],
    zones: [
        top(160, 'Tabs, LIVE & search'), bottom(600, 'Ad caption, CTA button & nav bar'),
        box(940, 700, 140, 620, 'Action buttons'),
        left(60, 'Edge', 'caution', 160, 1320), right(60, 'Edge', 'caution', 160, 700)
    ],
    mockup: ['reel', 'tiktokAd'],
    notes: 'Ads add a "Sponsored" label and a CTA button above the caption: keep the bottom 600 px clear.'
});
add('TikTok', 'Profile picture', 720, 720, { zones: [circleCrop()], notes: 'Displayed as a circle (200' + TIMES + '200 min).' });

// ---------------------------------------------------------------- Pinterest
add('Pinterest', 'Standard pin 2:3', 1000, 1500, {
    zones: pinZones(1500), mockup: ['pin'],
    notes: 'Recommended pin ratio. On hover, Pinterest overlays Save, the link and share buttons.'
});
add('Pinterest', 'Square pin', 1000, 1000, { zones: pinZones(1000), mockup: ['pin'], notes: '1:1.' });
add('Pinterest', 'Long pin', 1000, 2100, { zones: pinZones(2100), mockup: ['pin'], notes: '1:2.1 is the maximum; taller pins get cut off in the feed.' });
add('Pinterest', 'Video pin', 1080, 1920, {
    safe: [270, 195, 440, 65],
    zones: [top(270, 'Close, profile & menu'), bottom(440, 'Title, profile & Save'), right(195, 'Actions', 'danger', 270, 1480), left(65, 'Edge', 'caution', 270, 1480)],
    mockup: ['reel', 'pinterest'],
    notes: '9:16. Pinterest guidance: keep 270 px top, 440 px bottom, 65 px left and 195 px right clear.'
});
add('Pinterest', 'Profile cover', 1600, 900, {
    zones: [oval(680, 780, 240, 240, 'Profile photo ' + APPROX, 'caution')],
    mockup: ['avatar', { r: [680, 780, 240] }],
    notes: '16:9. The profile photo overlaps the bottom centre.'
});

// ---------------------------------------------------------------- Snapchat
add('Snapchat', 'Story / Spotlight / Snap Ad', 1080, 1920, {
    safe: [150, 60, 340, 60],
    zones: [top(150, 'Profile & menu'), bottom(340, 'Chat bar, CTA & attachment ' + APPROX), left(60, 'Edge', 'caution', 150, 1580), right(60, 'Edge', 'caution', 150, 1580)],
    mockup: ['story', { bars: 1, name: 'yourbrand', time: '2h', right: ['moreV'], pill: 'Send a chat', icons: ['heart'] }],
    notes: 'Snap Ads: brand name & headline at the top, CTA / swipe-up at the bottom.'
});

// ---------------------------------------------------------------- Threads
add('Threads', 'Post portrait 4:5', 1080, 1350, { notes: 'Shown up to 4:5 in the feed.' });
add('Threads', 'Post square', 1080, 1080, { notes: '1:1.' });

// ---------------------------------------------------------------- WhatsApp
add('WhatsApp', 'Status', 1080, 1920, {
    safe: [220, 40, 260, 40],
    zones: [top(220, 'Progress, name & time'), bottom(260, 'Reply & reactions'), left(40, '', 'caution', 220, 1660), right(40, '', 'caution', 220, 1660)],
    mockup: ['story', { back: true, name: 'Your Brand', time: 'Today, 10:42', right: ['moreV'], reply: true }],
    notes: 'Statuses disappear after 24 h. Video up to 60 s.'
});
add('WhatsApp', 'Profile picture', 640, 640, { zones: [circleCrop()], notes: 'Displayed as a circle.' });

// ---------------------------------------------------------------- Telegram
add('Telegram', 'Story', 1080, 1920, {
    safe: [220, 40, 260, 40],
    zones: [top(220, 'Progress, name & menu'), bottom(260, 'Reply & reactions')],
    mockup: ['story', { name: 'Your Channel', time: '2h', right: ['close', 'moreV'], pill: 'Reply privately\u2026', icons: ['heart'] }],
    notes: 'Channel & user stories, 9:16.'
});

// ---------------------------------------------------------------- Twitch
add('Twitch', 'Profile banner', 1200, 480, { notes: 'Max 10 MB. Displayed behind the channel header; keep content centred.' });
add('Twitch', 'Offline screen', 1920, 1080, { notes: 'Shown in the player when you are offline.' });
add('Twitch', 'Panel', 320, 160, { notes: 'Panels are 320 px wide, height up to 600 px.' });

// ---------------------------------------------------------------- Discord
add('Discord', 'Server banner', 960, 540, {
    zones: [top(190, 'Server name header ' + APPROX, 'caution')],
    notes: '16:9 (boost level 2). The server name header overlays the top of the banner.'
});
add('Discord', 'Profile banner', 680, 240, {
    zones: [oval(22, 148, 184, 184, 'Avatar ' + APPROX)],
    mockup: ['avatar', { r: [22, 148, 184] }],
    notes: 'Nitro profile banner. The avatar overlaps the bottom-left.'
});

// ---------------------------------------------------------------- Bluesky
add('Bluesky', 'Banner', 1500, 500, {
    zones: [oval(30, 340, 260, 260, 'Avatar ' + APPROX)],
    mockup: ['avatar', { r: [30, 340, 260] }],
    notes: '3:1. The avatar overlaps the bottom-left.'
});

// ---------------------------------------------------------------- Generic
add('Multi-platform', 'Universal vertical 9:16', 1080, 1920, {
    safe: [270, 195, 670, 65],
    zones: [
        top(270, 'Top UI (all platforms)'), bottom(670, 'Captions, CTAs & nav bars'),
        box(885, 760, 195, 490, 'Action rails'),
        left(65, 'Edge', 'caution', 270, 1250), right(65, 'Edge', 'caution', 270, 760),
        crop([3, 4], 'Instagram grid 3:4', false), crop([4, 5], 'Feed preview 4:5', false)
    ],
    notes: 'Combined safe zone of Instagram Reels & Stories, Facebook, TikTok, YouTube Shorts, Snapchat and Pinterest: design once, post everywhere.'
});
add('Multi-platform', 'Video 16:9 (title / action safe)', 1920, 1080, {
    safe: [54, 96, 54, 96],
    zones: [cropRect(67, 38, 1786, 1004, 'Action safe 93%', false, 'tr')],
    notes: 'Broadcast-style safe areas: green = title safe (90%), blue = action safe (93%).'
});

// =============================================================================
// 2. ICONS (24 x 24 grid, SVG-like path syntax: M L H V C Z, absolute only)
// =============================================================================

function pc(cx, cy, r) {
    var k = 0.5523 * r;
    return 'M' + cx + ' ' + (cy - r) +
        ' C' + (cx + k) + ' ' + (cy - r) + ' ' + (cx + r) + ' ' + (cy - k) + ' ' + (cx + r) + ' ' + cy +
        ' C' + (cx + r) + ' ' + (cy + k) + ' ' + (cx + k) + ' ' + (cy + r) + ' ' + cx + ' ' + (cy + r) +
        ' C' + (cx - k) + ' ' + (cy + r) + ' ' + (cx - r) + ' ' + (cy + k) + ' ' + (cx - r) + ' ' + cy +
        ' C' + (cx - r) + ' ' + (cy - k) + ' ' + (cx - k) + ' ' + (cy - r) + ' ' + cx + ' ' + (cy - r) + ' Z ';
}
function prr(x, y, w, h, r) {
    var k = 0.5523 * r;
    return 'M' + (x + r) + ' ' + y + ' H' + (x + w - r) +
        ' C' + (x + w - r + k) + ' ' + y + ' ' + (x + w) + ' ' + (y + r - k) + ' ' + (x + w) + ' ' + (y + r) +
        ' V' + (y + h - r) +
        ' C' + (x + w) + ' ' + (y + h - r + k) + ' ' + (x + w - r + k) + ' ' + (y + h) + ' ' + (x + w - r) + ' ' + (y + h) +
        ' H' + (x + r) +
        ' C' + (x + r - k) + ' ' + (y + h) + ' ' + x + ' ' + (y + h - r + k) + ' ' + x + ' ' + (y + h - r) +
        ' V' + (y + r) +
        ' C' + x + ' ' + (y + r - k) + ' ' + (x + r - k) + ' ' + y + ' ' + (x + r) + ' ' + y + ' Z ';
}

var ICONS = {
    heart: 'M12 21.35 L10.55 20.03 C5.4 15.36 2 12.28 2 8.5 C2 5.42 4.42 3 7.5 3 C9.24 3 10.91 3.81 12 5.09 C13.09 3.81 14.76 3 16.5 3 C19.58 3 22 5.42 22 8.5 C22 12.28 18.6 15.36 13.45 20.04 Z',
    comment: 'M12 2 C16.97 2 21 6.03 21 11 C21 15.97 16.97 20 12 20 C10.42 20 8.87 19.58 7.5 18.79 L3 21 L3.54 14.08 C3.18 13.09 3 12.05 3 11 C3 6.03 7.03 2 12 2 Z',
    bubble: 'M4 3.5 H20 C21.1 3.5 22 4.4 22 5.5 V15 C22 16.1 21.1 17 20 17 H10 L5 21 V17 H4 C2.9 17 2 16.1 2 15 V5.5 C2 4.4 2.9 3.5 4 3.5 Z',
    send: 'M11.7 20.3 L22 3 L2 3 L9.2 10.1 Z M22 3 L9.2 10.1',
    share: 'M13 4.5 L22 12 L13 19.5 V15.2 C7.6 15.2 4.6 16.6 2 20.5 C3 14.2 6.6 9.6 13 9 Z',
    bookmark: 'M6 3 H18 V21 L12 16.5 L6 21 Z',
    more: pc(5, 12, 1.9) + pc(12, 12, 1.9) + pc(19, 12, 1.9),
    moreV: pc(12, 5, 1.9) + pc(12, 12, 1.9) + pc(12, 19, 1.9),
    close: 'M5 5 L19 19 M19 5 L5 19',
    plus: 'M12 4 V20 M4 12 H20',
    search: pc(10.5, 10.5, 6.5) + 'M15.4 15.4 L21 21',
    camera: prr(2.5, 5.5, 19, 15, 3.5) + pc(12, 13, 4),
    music: 'M9 17.5 V5 L20 3 V15.5 ' + pc(6.5, 17.5, 2.5) + pc(17.5, 15.5, 2.5),
    thumb: 'M2 10 H6 V21 H2 Z M6 10 L10.5 3 C11.9 3 13 4.1 12.7 5.5 L12 9.5 H19.5 C20.9 9.5 21.9 10.8 21.5 12.2 L19.6 19.4 C19.3 20.4 18.4 21 17.4 21 H6 Z',
    repost: 'M4 10 V8 C4 6.9 4.9 6 6 6 H19 M16 3 L19 6 L16 9 M20 14 V16 C20 17.1 19.1 18 18 18 H5 M8 21 L5 18 L8 15',
    home: 'M3 10.5 L12 3 L21 10.5 V21 H15 V14.5 H9 V21 H3 Z',
    user: pc(12, 8, 4.2) + 'M3.5 21 C3.5 16.5 7.3 14 12 14 C16.7 14 20.5 16.5 20.5 21 Z',
    users: pc(9, 8, 3.6) + 'M2 20 C2 16 5 14 9 14 C13 14 16 16 16 20 Z ' + pc(17, 9, 2.8) + 'M17 14 C20 14 22 16 22 19',
    chevronDown: 'M6 9 L12 15 L18 9',
    chevronUp: 'M6 15 L12 9 L18 15',
    chevronLeft: 'M15 5 L8 12 L15 19',
    play: 'M7 4.5 L19.5 12 L7 19.5 Z',
    clock: pc(12, 12, 9) + 'M12 7 V12 L15.5 14.5',
    queue: 'M3 6 H17 M3 11 H17 M3 16 H10 M14 14 L21 17.5 L14 21 Z',
    fullscreen: 'M3 9 V3 H9 M15 3 H21 V9 M21 15 V21 H15 M9 21 H3 V15',
    gear: pc(12, 12, 3.2) + pc(12, 12, 8.5),
    remix: pc(12, 12, 8.5) + 'M8 12 H16 M12 8 V16'
};

// Parses the mini path syntax into [{closed, pts:[{a:[x,y], l:[x,y], r:[x,y]}]}]
// (l = incoming handle, r = outgoing handle)
function parsePath(d) {
    var toks = d.match(/[MLHVCZ]|-?\d*\.?\d+(?:e-?\d+)?/g), subs = [], cur = null, i = 0, cmd = '', x = 0, y = 0;
    function num() { return parseFloat(toks[i++]); }
    function push(px, py, l) { cur.pts.push({ a: [px, py], l: l || [px, py], r: [px, py] }); }
    while (i < toks.length) {
        if (/^[MLHVCZ]$/.test(toks[i])) cmd = toks[i++];
        if (cmd === 'Z') {
            var p = cur.pts, f0 = p[0], last = p[p.length - 1];
            if (p.length > 1 && Math.abs(last.a[0] - f0.a[0]) < 1e-6 && Math.abs(last.a[1] - f0.a[1]) < 1e-6) {
                f0.l = last.l; p.pop();
            }
            cur.closed = true; cmd = ''; continue;
        }
        if (cmd === 'M') { x = num(); y = num(); cur = { closed: false, pts: [] }; subs.push(cur); push(x, y); cmd = 'L'; }
        else if (cmd === 'L') { x = num(); y = num(); push(x, y); }
        else if (cmd === 'H') { x = num(); push(x, y); }
        else if (cmd === 'V') { y = num(); push(x, y); }
        else if (cmd === 'C') {
            var c1 = [num(), num()], c2 = [num(), num()];
            x = num(); y = num();
            cur.pts[cur.pts.length - 1].r = c1;
            push(x, y, c2);
        } else { i++; }
    }
    return subs;
}

// =============================================================================
// 3. PHOTOSHOP HELPERS (Action Manager)
// =============================================================================

function c2t(s) { return charIDToTypeID(s); }
function s2t(s) { return stringIDToTypeID(s); }

function rgbDesc(c) {
    var d = new ActionDescriptor();
    d.putDouble(c2t('Rd  '), c[0]); d.putDouble(c2t('Grn '), c[1]); d.putDouble(c2t('Bl  '), c[2]);
    return d;
}
function solid(c) { var s = new SolidColor(); s.rgb.red = c[0]; s.rgb.green = c[1]; s.rgb.blue = c[2]; return s; }

function boundsDesc(x, y, w, h, radius) {
    var s = new ActionDescriptor(), px = c2t('#Pxl');
    s.putInteger(s2t('unitValueQuadVersion'), 1);
    s.putUnitDouble(c2t('Top '), px, y); s.putUnitDouble(c2t('Left'), px, x);
    s.putUnitDouble(c2t('Btom'), px, y + h); s.putUnitDouble(c2t('Rght'), px, x + w);
    if (radius) {
        s.putUnitDouble(s2t('topRight'), px, radius); s.putUnitDouble(s2t('topLeft'), px, radius);
        s.putUnitDouble(s2t('bottomLeft'), px, radius); s.putUnitDouble(s2t('bottomRight'), px, radius);
    }
    return s;
}

// New shape layer (rectangle / ellipse) above the active layer
function makeShape(kind, x, y, w, h, color, radius) {
    var desc = new ActionDescriptor(), ref = new ActionReference();
    ref.putClass(s2t('contentLayer')); desc.putReference(c2t('null'), ref);
    var layer = new ActionDescriptor(), fill = new ActionDescriptor();
    fill.putObject(c2t('Clr '), c2t('RGBC'), rgbDesc(color));
    layer.putObject(c2t('Type'), s2t('solidColorLayer'), fill);
    layer.putObject(c2t('Shp '), c2t(kind === 'ellipse' ? 'Elps' : 'Rctn'), boundsDesc(x, y, w, h, kind === 'ellipse' ? 0 : radius));
    desc.putObject(c2t('Usng'), s2t('contentLayer'), layer);
    executeAction(c2t('Mk  '), desc, DialogModes.NO);
}

// Cuts a rectangle / ellipse out of the active shape layer
function subtractShape(kind, x, y, w, h) {
    var desc = new ActionDescriptor(), ref = new ActionReference();
    ref.putEnumerated(c2t('Path'), c2t('Ordn'), c2t('Trgt')); desc.putReference(c2t('null'), ref);
    desc.putObject(c2t('T   '), c2t(kind === 'ellipse' ? 'Elps' : 'Rctn'), boundsDesc(x, y, w, h));
    executeAction(c2t('SbtF'), desc, DialogModes.NO);
}

// Linear gradient shape layer (vertical), opacities in % at the top and bottom
function makeGradient(x, y, w, h, color, opTop, opBottom) {
    var desc = new ActionDescriptor(), ref = new ActionReference();
    ref.putClass(s2t('contentLayer')); desc.putReference(c2t('null'), ref);
    var layer = new ActionDescriptor(), gl = new ActionDescriptor(), grad = new ActionDescriptor();
    gl.putUnitDouble(c2t('Angl'), c2t('#Ang'), 90);
    gl.putEnumerated(c2t('Type'), c2t('GrdT'), c2t('Lnr '));
    gl.putBoolean(c2t('Algn'), true);
    grad.putString(c2t('Nm  '), 'Scrim');
    grad.putEnumerated(c2t('GrdF'), c2t('GrdF'), c2t('CstS'));
    grad.putDouble(c2t('Intr'), 4096);
    var clrs = new ActionList(), trns = new ActionList(), ops = [opBottom, opTop];
    for (var i = 0; i < 2; i++) {
        var st = new ActionDescriptor();
        st.putObject(c2t('Clr '), c2t('RGBC'), rgbDesc(color));
        st.putEnumerated(c2t('Type'), c2t('Clry'), c2t('UsrS'));
        st.putInteger(c2t('Lctn'), i * 4096); st.putInteger(c2t('Mdpn'), 50);
        clrs.putObject(c2t('Clrt'), st);
        var t = new ActionDescriptor();
        t.putUnitDouble(c2t('Opct'), c2t('#Prc'), ops[i]);
        t.putInteger(c2t('Lctn'), i * 4096); t.putInteger(c2t('Mdpn'), 50);
        trns.putObject(c2t('TrnS'), t);
    }
    grad.putList(c2t('Clrs'), clrs); grad.putList(c2t('Trns'), trns);
    gl.putObject(c2t('Grad'), c2t('Grdn'), grad);
    layer.putObject(c2t('Type'), s2t('gradientLayer'), gl);
    layer.putObject(c2t('Shp '), c2t('Rctn'), boundsDesc(x, y, w, h));
    desc.putObject(c2t('Usng'), s2t('contentLayer'), layer);
    executeAction(c2t('Mk  '), desc, DialogModes.NO);
}

// Shape layer from path data (see parsePath), positioned with a transform function
function makePathShape(subs, tf, color) {
    var doc = app.activeDocument, infos = [];
    for (var i = 0; i < subs.length; i++) {
        var pts = [];
        for (var j = 0; j < subs[i].pts.length; j++) {
            var p = subs[i].pts[j], ppi = new PathPointInfo();
            ppi.kind = PointKind.CORNERPOINT;
            ppi.anchor = tf(p.a); ppi.leftDirection = tf(p.r); ppi.rightDirection = tf(p.l);
            pts.push(ppi);
        }
        var spi = new SubPathInfo();
        // XOR keeps inner outlines (camera lens, clock hands) visible once stroked
        spi.closed = subs[i].closed; spi.operation = i ? ShapeOperation.SHAPEXOR : ShapeOperation.SHAPEADD;
        spi.entireSubPath = pts;
        infos.push(spi);
    }
    var path = doc.pathItems.add('ssz-tmp', infos);
    path.select();
    var desc = new ActionDescriptor(), ref = new ActionReference();
    ref.putClass(s2t('contentLayer')); desc.putReference(c2t('null'), ref);
    var layer = new ActionDescriptor(), fill = new ActionDescriptor();
    fill.putObject(c2t('Clr '), c2t('RGBC'), rgbDesc(color));
    layer.putObject(c2t('Type'), s2t('solidColorLayer'), fill);
    desc.putObject(c2t('Usng'), s2t('contentLayer'), layer);
    executeAction(c2t('Mk  '), desc, DialogModes.NO);
    path.remove();
}

// Stroke on the active shape layer. o: {fill, dash:[on,off], align:'inside'|'center'|'outside', round}
function setStroke(width, color, o) {
    o = o || {};
    var desc = new ActionDescriptor(), ref = new ActionReference();
    ref.putEnumerated(s2t('contentLayer'), c2t('Ordn'), c2t('Trgt')); desc.putReference(c2t('null'), ref);
    var style = new ActionDescriptor(), st = new ActionDescriptor();
    st.putInteger(s2t('strokeStyleVersion'), 2);
    st.putBoolean(s2t('strokeEnabled'), true);
    st.putBoolean(s2t('fillEnabled'), !!o.fill);
    st.putUnitDouble(s2t('strokeStyleLineWidth'), c2t('#Pxl'), width);
    var align = { inside: 'strokeStyleAlignInside', center: 'strokeStyleAlignCenter', outside: 'strokeStyleAlignOutside' }[o.align || 'inside'];
    st.putEnumerated(s2t('strokeStyleLineAlignment'), s2t('strokeStyleLineAlignment'), s2t(align));
    st.putEnumerated(s2t('strokeStyleLineCapType'), s2t('strokeStyleLineCapType'), s2t(o.round ? 'strokeStyleRoundCap' : 'strokeStyleButtCap'));
    st.putEnumerated(s2t('strokeStyleLineJoinType'), s2t('strokeStyleLineJoinType'), s2t(o.round ? 'strokeStyleRoundJoin' : 'strokeStyleMiterJoin'));
    var dash = new ActionList();
    if (o.dash) { dash.putUnitDouble(c2t('#Nne'), o.dash[0]); dash.putUnitDouble(c2t('#Nne'), o.dash[1]); }
    st.putList(s2t('strokeStyleLineDashSet'), dash);
    var content = new ActionDescriptor();
    content.putObject(c2t('Clr '), c2t('RGBC'), rgbDesc(color));
    st.putObject(s2t('strokeStyleContent'), s2t('solidColorLayer'), content);
    style.putObject(s2t('strokeStyle'), s2t('strokeStyle'), st);
    desc.putObject(c2t('T   '), s2t('shapeStyle'), style);
    executeAction(c2t('setd'), desc, DialogModes.NO);
}

// Layer style on the active layer / group. o: {shadow: [opacity, size], stroke: [size, colour]}
function setEffects(o) {
    var desc = new ActionDescriptor(), ref = new ActionReference();
    ref.putProperty(c2t('Prpr'), c2t('Lefx'));
    ref.putEnumerated(c2t('Lyr '), c2t('Ordn'), c2t('Trgt'));
    desc.putReference(c2t('null'), ref);
    var fx = new ActionDescriptor();
    fx.putUnitDouble(c2t('Scl '), c2t('#Prc'), 100);
    if (o.shadow) {
        var ds = new ActionDescriptor();
        ds.putBoolean(c2t('enab'), true);
        ds.putEnumerated(c2t('Md  '), c2t('BlnM'), c2t('Mltp'));
        ds.putObject(c2t('Clr '), c2t('RGBC'), rgbDesc([0, 0, 0]));
        ds.putUnitDouble(c2t('Opct'), c2t('#Prc'), o.shadow[0]);
        ds.putBoolean(c2t('uglg'), false);
        ds.putUnitDouble(c2t('lagl'), c2t('#Ang'), 90);
        ds.putUnitDouble(c2t('Dstn'), c2t('#Pxl'), Math.max(1, Math.round(o.shadow[1] / 4)));
        ds.putUnitDouble(c2t('Ckmt'), c2t('#Pxl'), 0);
        ds.putUnitDouble(c2t('blur'), c2t('#Pxl'), o.shadow[1]);
        fx.putObject(c2t('DrSh'), c2t('DrSh'), ds);
    }
    if (o.stroke) {
        var st = new ActionDescriptor();
        st.putBoolean(c2t('enab'), true);
        st.putEnumerated(c2t('Styl'), c2t('FStl'), c2t('OutF'));
        st.putEnumerated(c2t('PntT'), c2t('FrFl'), c2t('SClr'));
        st.putEnumerated(c2t('Md  '), c2t('BlnM'), c2t('Nrml'));
        st.putUnitDouble(c2t('Opct'), c2t('#Prc'), 100);
        st.putUnitDouble(c2t('Sz  '), c2t('#Pxl'), o.stroke[0]);
        st.putObject(c2t('Clr '), c2t('RGBC'), rgbDesc(o.stroke[1]));
        fx.putObject(c2t('FrFX'), c2t('FrFX'), st);
    }
    desc.putObject(c2t('T   '), c2t('Lefx'), fx);
    executeAction(c2t('setd'), desc, DialogModes.NO);
}

function addGuide(pos, horizontal) {
    var desc = new ActionDescriptor(), g = new ActionDescriptor();
    g.putUnitDouble(c2t('Pstn'), c2t('#Pxl'), pos);
    g.putEnumerated(c2t('Ornt'), c2t('Ornt'), c2t(horizontal ? 'Hrzn' : 'Vrtc'));
    desc.putObject(c2t('Nw  '), c2t('Gd  '), g);
    executeAction(c2t('Mk  '), desc, DialogModes.NO);
}

function layerBounds(l) {
    var b = l.bounds;
    return [b[0].as('px'), b[1].as('px'), b[2].as('px'), b[3].as('px')];
}

var FONT = null;
function fonts() {
    if (FONT) return FONT;
    var pick = function (names) {
        for (var i = 0; i < names.length; i++) {
            try { app.fonts.getByName(names[i]); return names[i]; } catch (e) {}
        }
        return names[names.length - 1];
    };
    FONT = {
        reg: pick(['HelveticaNeue', 'ArialMT']),
        bold: pick(['HelveticaNeue-Bold', 'Arial-BoldMT'])
    };
    return FONT;
}

// =============================================================================
// 4. DRAWER - creates layers at the top of a group, in call order
// =============================================================================

var WHITE = [255, 255, 255], BLACK = [0, 0, 0], GREY = [170, 170, 170];

function Drawer(doc, group) { this.doc = doc; this.group = group; }

Drawer.prototype._prep = function () {
    // A new layer is created above the active one; with an empty group active, it goes inside.
    this.doc.activeLayer = this.group.layers.length ? this.group.layers[0] : this.group;
};
Drawer.prototype._done = function (name, o) {
    var l = this.doc.activeLayer;
    l.name = name;
    if (o && o.o !== undefined && o.o < 100) l.opacity = o.o;
    return l;
};
// o: {c: fill colour, o: opacity, r: radius, stroke: width, sc: stroke colour, fill: keep fill with stroke, dash, align}
Drawer.prototype.rect = function (name, x, y, w, h, o) {
    o = o || {};
    this._prep();
    makeShape('rect', x, y, w, h, o.c || WHITE, o.r);
    if (o.stroke) setStroke(o.stroke, o.sc || o.c || WHITE, { fill: o.fill, dash: o.dash, align: o.align });
    return this._done(name, o);
};
Drawer.prototype.ellipse = function (name, x, y, w, h, o) {
    o = o || {};
    this._prep();
    makeShape('ellipse', x, y, w, h, o.c || WHITE);
    if (o.stroke) setStroke(o.stroke, o.sc || o.c || WHITE, { fill: o.fill, dash: o.dash, align: o.align });
    return this._done(name, o);
};
Drawer.prototype.circle = function (name, cx, cy, d, o) { return this.ellipse(name, cx - d / 2, cy - d / 2, d, d, o); };
// Filled rectangle minus an ellipse
Drawer.prototype.outside = function (name, x, y, w, h, hole, o) {
    o = o || {};
    this._prep();
    makeShape('rect', x, y, w, h, o.c || WHITE);
    subtractShape('ellipse', hole[0], hole[1], hole[2], hole[3]);
    return this._done(name, o);
};
Drawer.prototype.scrim = function (name, x, y, w, h, opTop, opBottom) {
    this._prep();
    try {
        makeGradient(x, y, w, h, BLACK, opTop, opBottom);
    } catch (e) {
        makeShape('rect', x, y, w, h, BLACK);
        this.doc.activeLayer.opacity = Math.max(opTop, opBottom) / 2;
    }
    return this._done(name);
};
// o: {c, o, fill: true for a filled icon, sw: stroke width in 24-grid units, flipY}
Drawer.prototype.icon = function (name, id, cx, cy, size, o) {
    o = o || {};
    var sc = size / 24, fy = o.flipY ? -1 : 1;
    var tf = function (p) { return [cx + (p[0] - 12) * sc, cy + (p[1] - 12) * sc * fy]; };
    this._prep();
    makePathShape(parsePath(ICONS[id]), tf, o.c || WHITE);
    if (!o.fill) setStroke((o.sw || 1.9) * sc, o.c || WHITE, { align: 'center', round: true });
    return this._done(name, o);
};
// o: {c, o, bold, align: 'left'|'center'|'right'}
Drawer.prototype.text = function (name, str, x, y, size, o) {
    o = o || {};
    var l = this.group.artLayers.add();
    l.kind = LayerKind.TEXT;
    var t = l.textItem;
    t.contents = str;
    t.font = o.bold ? fonts().bold : fonts().reg;
    t.size = new UnitValue(size, 'px');
    t.color = solid(o.c || WHITE);
    t.justification = o.align === 'center' ? Justification.CENTER : (o.align === 'right' ? Justification.RIGHT : Justification.LEFT);
    t.position = [new UnitValue(x, 'px'), new UnitValue(y, 'px')];
    l.name = name;
    if (o.o !== undefined && o.o < 100) l.opacity = o.o;
    return l;
};
Drawer.prototype.right = function (layer) { return layerBounds(layer)[2]; };

// =============================================================================
// 5. UI MOCKUPS
// =============================================================================

var MOCKUPS = {};

// ---- Stories (Instagram, Facebook, Snapchat, WhatsApp, Telegram)
MOCKUPS.story = function (D, f, c) {
    var W = f.w, H = f.h, i;
    D.scrim('Top shade', 0, 0, W, 330, 45, 0);
    D.scrim('Bottom shade', 0, H - 320, W, 320, 0, 50);
    var n = c.bars || 3, mx = 16, gap = 8, bw = (W - 2 * mx - (n - 1) * gap) / n;
    for (i = 0; i < n; i++) D.rect('Progress ' + (i + 1), mx + i * (bw + gap), 28, bw, 6, { r: 3, o: i === 0 ? 100 : 45 });
    var ax = 32, ay = 64;
    if (c.back) { D.icon('Back', 'chevronLeft', 52, ay + 44, 56); ax = 92; }
    D.ellipse('Avatar', ax, ay, 88, 88, { c: GREY });
    D.icon('Avatar icon', 'user', ax + 44, ay + 46, 54, { fill: true, c: [235, 235, 235] });
    var nm = D.text('Name', c.name, ax + 108, ay + 56, 32, { bold: true });
    if (c.time) D.text('Time', c.time, D.right(nm) + 16, ay + 56, 30, { o: 75 });
    for (i = 0; i < (c.right || []).length; i++) D.icon(c.right[i], c.right[i], W - 56 - i * 96, ay + 44, 56);
    if (c.reply) {
        D.icon('Reply arrow', 'chevronUp', W / 2, H - 140, 52);
        D.text('Reply', 'Reply', W / 2, H - 70, 32, { bold: true, align: 'center' });
        return;
    }
    var icons = c.icons || [], pillW = W - 64 - icons.length * 110;
    D.rect('Message field', 32, H - 150, pillW, 96, { r: 48, stroke: 3, o: 90 });
    D.text('Message placeholder', c.pill, 76, H - 90, 32, { o: 90 });
    for (i = 0; i < icons.length; i++) D.icon(icons[i], icons[i], 32 + pillW + 62 + i * 110, H - 102, 60);
};

// ---- Vertical video feeds (Reels, TikTok, Shorts, LinkedIn, Pinterest)
var REEL_CFG = {
    instagram: {
        header: 'Reels', chevron: true, topIcons: ['camera'], railTop: 1110, rail: [
            { icon: 'heart', text: '12.4K' }, { icon: 'comment', text: '348' }, { icon: 'send', text: '1,024' }, { icon: 'more' }, { audio: true }
        ],
        name: 'yourbrand', avatar: true, follow: 'Follow', bottomY: 1640,
        caption: 'Your caption goes here\u2026 more', audio: 'yourbrand' + DOT + 'Original audio'
    },
    facebook: {
        header: 'Reels', topIcons: ['user', 'search'], railTop: 1110, rail: [
            { icon: 'thumb', text: '2.1K' }, { icon: 'bubble', text: '348' }, { icon: 'share', text: 'Share' }, { icon: 'more' }
        ],
        name: 'Your Page', avatar: true, follow: 'Follow', bottomY: 1640,
        caption: 'Your caption goes here\u2026 See more', audio: 'Your Page' + DOT + 'Original audio'
    },
    tiktok: {
        tabs: true, filled: true, railTop: 880, rail: [
            { tiktokAvatar: true }, { icon: 'heart', text: '1.2M' }, { icon: 'bubble', text: '8,421' },
            { icon: 'bookmark', text: '52K' }, { icon: 'share', text: '12K' }, { disc: true }
        ],
        name: '@yourbrand', bottomY: 1590, caption: 'Your caption goes here #fyp',
        audio: 'original sound - yourbrand', nav: true
    },
    tiktokAd: {
        tabs: true, filled: true, railTop: 760, rail: [
            { tiktokAvatar: true }, { icon: 'heart', text: '24K' }, { icon: 'bubble', text: '311' }, { icon: 'share', text: 'Share' }, { disc: true }
        ],
        name: 'yourbrand', sponsored: true, bottomY: 1440, caption: 'Your ad caption goes here',
        audio: 'Promoted music', cta: 'Learn more', nav: true
    },
    shorts: {
        topIcons: ['moreV', 'search'], iconBg: true, railTop: 980, railStep: 145, rail: [
            { icon: 'thumb', text: '12K' }, { icon: 'thumb', text: 'Dislike', flip: true }, { icon: 'bubble', text: '348' },
            { icon: 'share', text: 'Share' }, { icon: 'remix', text: 'Remix' }, { audio: true }
        ],
        name: '@yourbrand', avatar: true, follow: 'Subscribe', followFilled: true, bottomY: 1600,
        caption: 'Your Short title goes here', audio: 'Original sound' + DOT + 'yourbrand'
    },
    linkedin: {
        topIcons: ['more'], back: true, railTop: 1000, rail: [
            { icon: 'thumb', text: 'Like' }, { icon: 'bubble', text: 'Comment' }, { icon: 'repost', text: 'Repost' }, { icon: 'send', text: 'Send' }
        ],
        name: 'Your Name', avatar: true, follow: '+ Follow', bottomY: 1560, caption: 'Your post text goes here\u2026 more'
    },
    pinterest: {
        topIcons: ['more'], back: true, railTop: 1040, rail: [
            { icon: 'heart', text: '1.4K' }, { icon: 'bubble', text: '36' }, { icon: 'share' }
        ],
        name: 'yourbrand', avatar: true, follow: 'Follow', bottomY: 1580, caption: 'Your pin title goes here', save: true
    }
};

MOCKUPS.reel = function (D, f, key) {
    var c = REEL_CFG[key], W = f.w, H = f.h, i, y, l, b;
    D.scrim('Top shade', 0, 0, W, 360, 45, 0);
    D.scrim('Bottom shade', 0, H - 760, W, 760, 0, 60);

    // header
    if (c.tabs) {
        D.rect('LIVE badge', 36, 74, 104, 52, { r: 10, stroke: 3 });
        D.text('LIVE', 'LIVE', 88, 111, 26, { bold: true, align: 'center' });
        D.text('Tab Following', 'Following', W / 2 - 24, 112, 34, { bold: true, align: 'right', o: 65 });
        l = D.text('Tab For You', 'For You', W / 2 + 24, 112, 36, { bold: true });
        b = layerBounds(l);
        D.rect('Tab underline', (b[0] + b[2]) / 2 - 30, 128, 60, 6, { r: 3 });
        D.icon('Search', 'search', W - 70, 100, 60);
    } else if (c.header) {
        l = D.text('Header', c.header, 40, 140, 50, { bold: true });
        if (c.chevron) D.icon('Header chevron', 'chevronDown', D.right(l) + 34, 124, 40);
    } else if (c.back) {
        D.icon('Back', 'chevronLeft', 60, 120, 60);
    }
    for (i = 0; i < (c.topIcons || []).length; i++) D.icon(c.topIcons[i], c.topIcons[i], W - 70 - i * 100, 120, 60);

    // action rail
    var rx = W - 70, step = c.railStep || 150;
    y = c.railTop;
    for (i = 0; i < c.rail.length; i++) {
        var it = c.rail[i];
        if (it.tiktokAvatar) {
            D.circle('Profile ring', rx, y, 100, { c: WHITE });
            D.circle('Profile', rx, y, 92, { c: GREY });
            D.icon('Profile icon', 'user', rx, y + 2, 56, { fill: true, c: [235, 235, 235] });
            D.circle('Follow badge', rx, y + 50, 40, { c: [254, 44, 85] });
            D.icon('Follow plus', 'plus', rx, y + 50, 26, { sw: 3 });
            y += 170; continue;
        }
        if (it.disc) {
            D.circle('Sound disc', rx, y, 92, { c: [35, 35, 35] });
            D.circle('Sound disc cover', rx, y, 48, { c: GREY });
            y += step; continue;
        }
        if (it.audio) {
            D.rect('Audio cover', rx - 34, y - 34, 68, 68, { r: 12, c: GREY, stroke: 4, sc: WHITE, fill: true });
            y += step; continue;
        }
        if (c.iconBg) D.circle('Icon background', rx, y, 96, { c: BLACK, o: 30 });
        D.icon(it.text || it.icon, it.icon, rx, y, c.iconBg ? 56 : 68, { fill: !!c.filled, flipY: it.flip });
        if (it.text) D.text('Count', it.text, rx, y + (c.iconBg ? 84 : 78), 26, { bold: true, align: 'center' });
        y += step;
    }

    // author, caption, audio
    var by = c.bottomY, tx = 32;
    if (c.sponsored) D.text('Sponsored', 'Sponsored', 32, by - 56, 26, { o: 75 });
    if (c.avatar) {
        D.ellipse('Author avatar', 32, by - 52, 72, 72, { c: GREY });
        D.icon('Author avatar icon', 'user', 68, by - 15, 44, { fill: true, c: [235, 235, 235] });
        tx = 120;
    }
    l = D.text('Author', c.name, tx, by, 32, { bold: true });
    if (c.follow) {
        var fx = D.right(l) + 22, fw = c.follow.length * 17 + 44;
        if (c.followFilled) {
            D.rect('Follow button', fx, by - 42, fw, 58, { r: 29 });
            D.text('Follow label', c.follow, fx + fw / 2, by - 3, 26, { bold: true, align: 'center', c: BLACK });
        } else {
            D.rect('Follow button', fx, by - 42, fw, 58, { r: 14, stroke: 3 });
            D.text('Follow label', c.follow, fx + fw / 2, by - 3, 26, { bold: true, align: 'center' });
        }
    }
    D.text('Caption', c.caption, 32, by + 76, 30, { o: 95 });
    if (c.audio) {
        D.icon('Audio icon', 'music', 50, by + 128, 34);
        D.text('Audio', c.audio, 80, by + 140, 28, { o: 90 });
    }
    if (c.cta) {
        D.rect('CTA button', 32, by + 180, W - 64 - 150, 80, { r: 8, c: [254, 44, 85] });
        D.text('CTA label', c.cta, 32 + (W - 64 - 150) / 2, by + 232, 32, { bold: true, align: 'center' });
    }
    if (c.save) {
        D.rect('Save button', W - 200, by + 60, 168, 80, { r: 40, c: [230, 0, 35] });
        D.text('Save label', 'Save', W - 116, by + 112, 32, { bold: true, align: 'center' });
    }

    // bottom navigation (TikTok overlays it on 16:9 phones)
    if (c.nav) {
        var ny = H - 150, labels = ['Home', 'Friends', '', 'Inbox', 'Profile'], ic = ['home', 'users', '', 'bubble', 'user'];
        D.rect('Nav bar', 0, ny, W, 150, { c: BLACK, o: 92 });
        for (i = 0; i < 5; i++) {
            var cx = W * (i + 0.5) / 5;
            if (i === 2) {
                D.rect('Create button', cx - 52, ny + 40, 104, 68, { r: 16 });
                D.icon('Create plus', 'plus', cx, ny + 74, 40, { c: BLACK, sw: 2.6 });
                continue;
            }
            D.icon(labels[i], ic[i], cx, ny + 58, 52, { fill: i === 0, o: i === 0 ? 100 : 80 });
            D.text(labels[i] + ' label', labels[i], cx, ny + 118, 22, { align: 'center', bold: i === 0, o: i === 0 ? 100 : 80 });
        }
    }
};

// ---- Instagram feed overlays
MOCKUPS.igFeed = function (D, f) {
    D.rect('Carousel counter', f.w - 124, 24, 100, 56, { r: 28, c: [20, 20, 20], o: 70 });
    D.text('Carousel counter label', '1/10', f.w - 74, 62, 28, { bold: true, align: 'center' });
    if (f.h >= 1000) {
        D.circle('Tags button', 56, f.h - 56, 64, { c: [20, 20, 20], o: 70 });
        D.icon('Tags icon', 'user', 56, f.h - 55, 34, { fill: true });
    }
};

// ---- Profile photo over a banner / cover. c.r = [x, y, diameter], c.square for logos
MOCKUPS.avatar = function (D, f, c) {
    var x = c.r[0], y = c.r[1], d = c.r[2], ring = Math.max(4, Math.round(d * 0.03));
    if (c.square) {
        D.rect('Logo border', x, y, d, d, { r: d * 0.08 });
        D.rect('Logo', x + ring, y + ring, d - 2 * ring, d - 2 * ring, { r: d * 0.06, c: GREY });
    } else {
        D.ellipse('Profile photo border', x, y, d, d);
        D.ellipse('Profile photo', x + ring, y + ring, d - 2 * ring, d - 2 * ring, { c: GREY });
    }
    D.icon('Profile icon', 'user', x + d / 2, y + d / 2 + d * 0.02, d * 0.6, { fill: true, c: [235, 235, 235] });
};

// ---- X link card
MOCKUPS.xCard = function (D, f) {
    D.rect('Domain label', 24, 552, 300, 52, { r: 8, c: BLACK, o: 70 });
    D.text('Domain', 'yourbrand.com', 40, 588, 28);
};

// ---- LinkedIn document viewer
MOCKUPS.liDoc = function (D, f) {
    D.rect('Title bar', 0, 0, f.w, 80, { c: BLACK, o: 70 });
    D.text('Title', 'Your document title', 32, 52, 28, { bold: true });
    D.rect('Page counter', 24, f.h - 66, 140, 48, { r: 24, c: BLACK, o: 70 });
    D.text('Page counter label', '1 / 10', 94, f.h - 32, 26, { bold: true, align: 'center' });
    D.icon('Fullscreen', 'fullscreen', f.w - 50, f.h - 42, 36);
};

// ---- YouTube
var YT_RED = [255, 0, 51];
MOCKUPS.ytThumb = function (D, f) {
    D.rect('Progress track', 0, f.h - 12, f.w, 12, { c: [120, 120, 120], o: 70 });
    D.rect('Progress watched', 0, f.h - 12, f.w * 0.42, 12, { c: YT_RED });
    D.rect('Duration badge', 1110, 628, 142, 64, { r: 10, c: BLACK, o: 80 });
    D.text('Duration', '12:34', 1181, 673, 34, { bold: true, align: 'center' });
    D.rect('Watch later button', 1180, 24, 76, 76, { r: 10, c: BLACK, o: 80 });
    D.icon('Watch later icon', 'clock', 1218, 62, 44);
    D.rect('Add to queue button', 1180, 112, 76, 76, { r: 10, c: BLACK, o: 80 });
    D.icon('Add to queue icon', 'queue', 1218, 150, 44);
};
MOCKUPS.ytPlayer = function (D, f) {
    var W = f.w, H = f.h;
    D.scrim('Top shade', 0, 0, W, 180, 55, 0);
    D.scrim('Bottom shade', 0, H - 260, W, 260, 0, 65);
    D.circle('Channel avatar', 66, 62, 64, { c: GREY });
    D.text('Video title', 'Your video title', 116, 74, 34);
    D.rect('Progress track', 24, H - 98, W - 48, 6, { c: WHITE, o: 35 });
    D.rect('Progress played', 24, H - 98, 640, 6, { c: YT_RED });
    D.circle('Scrubber', 664, H - 95, 24, { c: YT_RED });
    D.icon('Play', 'play', 66, H - 42, 48, { fill: true });
    D.text('Time', '4:12 / 12:34', 120, H - 32, 28);
    D.icon('Settings', 'gear', W - 160, H - 42, 44);
    D.icon('Fullscreen', 'fullscreen', W - 66, H - 42, 44);
};
MOCKUPS.ytEnd = function (D, f) {
    var tiles = [[190, 330], [1010, 330]];
    for (var i = 0; i < 2; i++) {
        D.rect('Video element ' + (i + 1), tiles[i][0], tiles[i][1], 720, 405, { r: 12, c: [25, 25, 25], o: 90 });
        D.icon('Video element play', 'play', tiles[i][0] + 360, tiles[i][1] + 202, 90, { fill: true, o: 80 });
    }
    D.circle('Subscribe element', 960, 895, 250, { c: GREY });
    D.icon('Subscribe icon', 'user', 960, 900, 150, { fill: true, c: [235, 235, 235] });
};

// ---- Pinterest pin hover state
MOCKUPS.pin = function (D, f) {
    var H = f.h;
    D.rect('Save button', 690, 50, 260, 150, { r: 75, c: [230, 0, 35] });
    D.text('Save label', 'Save', 820, 148, 64, { bold: true, align: 'center' });
    D.rect('Link button', 50, H - 186, 520, 136, { r: 68, o: 92 });
    D.text('Link label', 'yourbrand.com', 310, H - 100, 52, { bold: true, align: 'center', c: BLACK });
    D.circle('Share button', 738, H - 118, 136, { o: 92 });
    D.icon('Share icon', 'share', 738, H - 118, 64, { fill: true, c: BLACK });
    D.circle('More button', 882, H - 118, 136, { o: 92 });
    D.icon('More icon', 'more', 882, H - 118, 64, { fill: true, c: BLACK });
};

// =============================================================================
// 6. ZONE GEOMETRY (shared by the builder and the dialog preview)
// =============================================================================

var KIND = {
    danger: { fill: [255, 40, 70], text: [190, 0, 40], group: 'Covered by UI', op: 1 },
    caution: { fill: [255, 160, 0], text: [175, 95, 0], group: 'May be covered', op: 0.9 },
    crop: { fill: [0, 140, 255], text: [0, 95, 200], group: 'Cropped in some views', op: 0.8 },
    safe: { fill: [0, 200, 110], text: [0, 135, 75] }
};

function round(v) { return Math.round(v); }

// Returns { fills: [...], frames: [...], guides: {h: [], v: []} }
function resolve(f) {
    var W = f.w, H = f.h, fills = [], frames = [], gh = [], gv = [], i, z, r;
    function frameGuides(x, y, w, h) { gv.push(x, x + w); gh.push(y, y + h); }
    for (i = 0; i < f.zones.length; i++) {
        z = f.zones[i];
        if (z.t === 'band') {
            var a = z.from || 0, b, lab = z.label ? z.label + DOT + z.size + ' px' : '';
            if (z.side === 'top') { b = z.to || W; r = [a, 0, b - a, z.size]; }
            else if (z.side === 'bottom') { b = z.to || W; r = [a, H - z.size, b - a, z.size]; }
            else if (z.side === 'left') { b = z.to || H; r = [0, a, z.size, b - a]; }
            else { b = z.to || H; r = [W - z.size, a, z.size, b - a]; }
            fills.push({ shape: 'rect', r: r, k: z.k, label: lab, side: z.side });
        } else if (z.t === 'rect' || z.t === 'ellipse') {
            fills.push({ shape: z.t, r: z.r, k: z.k, label: z.label });
        } else if (z.t === 'outside') {
            var d = Math.min(W, H);
            r = z.r || [(W - d) / 2, (H - d) / 2, d, d];
            fills.push({ shape: 'outside', r: r, k: z.k, label: z.label });
            frames.push({ shape: 'ellipse', r: r, k: z.k });
            frameGuides(r[0], r[1], r[2], r[3]);
        } else if (z.t === 'crop') {
            if (z.r) r = z.r;
            else {
                var ar = z.ratio[0] / z.ratio[1], cw = W, ch = H;
                if (W / H > ar) cw = round(H * ar); else ch = round(W / ar);
                r = [round((W - cw) / 2), round((H - ch) / 2), cw, ch];
            }
            if (z.tint) {
                if (r[1] > 0) fills.push({ shape: 'rect', r: [0, 0, W, r[1]], k: 'crop' });
                if (r[1] + r[3] < H) fills.push({ shape: 'rect', r: [0, r[1] + r[3], W, H - r[1] - r[3]], k: 'crop' });
                if (r[0] > 0) fills.push({ shape: 'rect', r: [0, r[1], r[0], r[3]], k: 'crop' });
                if (r[0] + r[2] < W) fills.push({ shape: 'rect', r: [r[0] + r[2], r[1], W - r[0] - r[2], r[3]], k: 'crop' });
            }
            frames.push({ shape: 'rect', r: r, k: 'crop', label: z.label, lp: z.lp || 'tl' });
            frameGuides(r[0], r[1], r[2], r[3]);
        }
    }
    if (f.safe) {
        var s = f.safe;
        r = [s[3], s[0], W - s[1] - s[3], H - s[0] - s[2]];
        frames.push({ shape: 'rect', r: r, k: 'safe', label: 'Safe area' + DOT + 'text & logos', lp: f.safeLabel || 'bl' });
        frameGuides(r[0], r[1], r[2], r[3]);
    }
    return { fills: fills, frames: frames, guides: { h: gh, v: gv } };
}

// =============================================================================
// 7. DOCUMENT BUILDER
// =============================================================================

var BACKGROUNDS = [
    { name: 'White', c: [255, 255, 255] },
    { name: 'Light grey', c: [237, 237, 237] },
    { name: 'Dark grey', c: [40, 40, 40] },
    { name: 'Black', c: [0, 0, 0] },
    { name: 'Transparent', c: null }
];

function clip(r, W, H) {
    var x0 = Math.max(0, r[0]), y0 = Math.max(0, r[1]), x1 = Math.min(W, r[0] + r[2]), y1 = Math.min(H, r[1] + r[3]);
    return [x0, y0, Math.max(0, x1 - x0), Math.max(0, y1 - y0)];
}

// Centred, auto-fitting label inside a rectangle (rotated for tall narrow areas)
function fitLabel(D, str, r, size, color) {
    if (!str || r[2] < 8 || r[3] < 8) return;
    var rotate = r[3] > r[2] * 1.5;
    var cx = r[0] + r[2] / 2, cy = r[1] + r[3] / 2;
    var l = D.text(str, str, cx, cy + size * 0.35, size, { bold: true, align: 'center', c: color });
    if (rotate) l.rotate(-90, AnchorPosition.MIDDLECENTER);
    var b = layerBounds(l), bw = b[2] - b[0], bh = b[3] - b[1];
    var k = Math.min(1, (r[2] * 0.9) / bw, (r[3] * 0.75) / bh);
    if (k < 1) {
        if (size * k < 9) { l.remove(); return; }
        l.textItem.size = new UnitValue(size * k, 'px');
        b = layerBounds(l);
    }
    l.translate(cx - (b[0] + b[2]) / 2, cy - (b[1] + b[3]) / 2);
}

// Label next to a small zone (buttons, badges, avatars, action rails) so it never covers the mockup:
// below or above it, or beside it for tall rails.
function sideLabel(D, str, r, W, H, size, color) {
    var v = clip(r, W, H);
    if (!str || v[2] < 1 || v[3] < 1) return;
    var tall = v[3] > v[2] * 1.5, gap = size * 0.5;
    var l = D.text(str, str, v[0] + v[2] / 2, v[1] + v[3] / 2, size, { bold: true, align: 'center', c: color });
    if (tall) l.rotate(-90, AnchorPosition.MIDDLECENTER);
    var b = layerBounds(l), bw = b[2] - b[0], bh = b[3] - b[1], tx, ty;
    if (tall) {
        tx = v[0] + v[2] / 2 > W / 2 ? v[0] - gap - bw / 2 : v[0] + v[2] + gap + bw / 2;
        ty = v[1] + v[3] / 2;
    } else {
        tx = v[0] + v[2] / 2;
        ty = v[1] + v[3] + gap + bh <= H - gap ? v[1] + v[3] + gap + bh / 2 : v[1] - gap - bh / 2;
    }
    tx = Math.max(bw / 2 + gap, Math.min(W - bw / 2 - gap, tx));
    ty = Math.max(bh / 2 + gap, Math.min(H - bh / 2 - gap, ty));
    l.translate(tx - (b[0] + b[2]) / 2, ty - (b[1] + b[3]) / 2);
}

function cornerLabel(D, str, r, lp, size, color) {
    if (!str) return;
    var pad = Math.round(size * 0.6), right = lp === 'tr' || lp === 'br', bottom = lp === 'bl' || lp === 'br';
    var x = right ? r[0] + r[2] - pad : r[0] + pad;
    var y = bottom ? r[1] + r[3] - pad : r[1] + pad + size * 0.8;
    D.text(str, str, x, y, size, { bold: true, align: right ? 'right' : 'left', c: color });
}

function build(doc, f, opts) {
    var W = f.w, H = f.h, i;
    var S = Math.max(12, Math.min(40, Math.round(Math.sqrt(W * H) / 48)));

    // background
    var bg = BACKGROUNDS[opts.bg] || BACKGROUNDS[0];
    if (bg.c && opts.bg > 0) {
        doc.activeLayer = doc.backgroundLayer;
        doc.selection.selectAll(); doc.selection.fill(solid(bg.c)); doc.selection.deselect();
    }

    // artwork group (a transparent canvas starts with an empty layer: replace it)
    var initial = doc.layers.length === 1 && !doc.layers[0].isBackgroundLayer ? doc.layers[0] : null;
    var art = doc.layerSets.add();
    art.name = 'ARTWORK';
    var design = art.artLayers.add();
    design.name = 'Your design';
    if (initial) initial.remove();

    // Everything that is not artwork lives in one group, so a single click hides it before export:
    //   Labels / Frames / UI mockup / Covered by UI / May be covered / Cropped in some views
    var geo = resolve(f);
    var wantMockup = opts.mockup && f.mockup && MOCKUPS[f.mockup[0]];
    var wantZones = (opts.zones || opts.labels) && (geo.fills.length || geo.frames.length);
    if (wantMockup || wantZones) {
        var root = doc.layerSets.add();
        root.name = '\u26A0 SAFE ZONES' + DOT + 'hide before export';
        var groups = {}, order = ['crop', 'caution', 'danger'], locked = [];
        for (i = 0; i < order.length; i++) {
            var g = root.layerSets.add();
            g.name = KIND[order[i]].group;
            g.blendMode = BlendMode.NORMAL; // isolate: overlapping zones keep one even tint
            g.opacity = Math.round(opts.opacity * KIND[order[i]].op);
            groups[order[i]] = g;
            locked.push(g);
        }

        // UI mockup, above the tinted areas so it stays crisp
        if (wantMockup) {
            var mock = root.layerSets.add();
            mock.name = 'UI mockup' + DOT + f.platform + ' ' + f.name;
            MOCKUPS[f.mockup[0]](new Drawer(doc, mock), f, f.mockup[1] || {});
            doc.activeLayer = mock;
            try { setEffects({ shadow: [45, Math.max(4, Math.round(S / 2))] }); } catch (e) {}
        }

        var gFrames = root.layerSets.add(); gFrames.name = 'Frames';
        var gLabels = root.layerSets.add(); gLabels.name = 'Labels';
        locked.push(gFrames, gLabels);
        var DL = new Drawer(doc, gLabels), DF = new Drawer(doc, gFrames);

        for (i = 0; i < geo.fills.length; i++) {
            var z = geo.fills[i], r = z.r, kd = KIND[z.k], name = z.label || kd.group;
            if (opts.zones) {
                var D = new Drawer(doc, groups[z.k]);
                if (z.shape === 'outside') D.outside(name, 0, 0, W, H, r, { c: kd.fill });
                else if (z.shape === 'ellipse') D.ellipse(name, r[0], r[1], r[2], r[3], { c: kd.fill });
                else D.rect(name, r[0], r[1], r[2], r[3], { c: kd.fill });
            }
            if (opts.labels && z.label) {
                var lr = clip(r, W, H), lh = Math.min(lr[3], S * 1.6);
                // band labels hug the safe-area side, where the platform UI rarely sits
                if (z.side === 'top') lr = [lr[0], lr[1] + lr[3] - lh, lr[2], lh];
                else if (z.side === 'bottom') lr = [lr[0], lr[1], lr[2], lh];
                else if (z.shape === 'outside') lr = r[1] > H * 0.08 ? [0, 0, W, r[1]] : [0, 0, W * 0.18, H * 0.18];
                else if (lr[2] * lr[3] < W * H * 0.15) { sideLabel(DL, z.label, r, W, H, Math.round(S * 0.85), kd.text); continue; }
                fitLabel(DL, z.label, lr, S, kd.text);
            }
        }
        var lw = Math.max(2, Math.round(S / 10));
        for (i = 0; i < geo.frames.length; i++) {
            var fr = geo.frames[i], kf = KIND[fr.k], fname = fr.label || (fr.k === 'safe' ? 'Safe area' : 'Frame');
            if (opts.zones) {
                if (fr.shape === 'ellipse') DF.ellipse(fname, fr.r[0], fr.r[1], fr.r[2], fr.r[3], { c: kf.fill, stroke: lw, dash: [4, 3] });
                else DF.rect(fname, fr.r[0], fr.r[1], fr.r[2], fr.r[3], { c: kf.fill, stroke: lw, dash: fr.k === 'safe' ? null : [4, 3] });
            }
            if (opts.labels) cornerLabel(DL, fr.label, fr.r, fr.lp, Math.round(S * 0.8), kf.text);
        }
        if (gLabels.layers.length) {
            doc.activeLayer = gLabels;
            try { setEffects({ stroke: [Math.max(2, Math.round(S / 9)), WHITE] }); } catch (e) {}
        }
        // drop empty groups, lock the guides (the mockup stays editable)
        for (i = 0; i < locked.length; i++) {
            if (!locked[i].layers.length) locked[i].remove();
            else locked[i].allLocked = true;
        }
    }

    // guides
    if (opts.guides || opts.center) {
        var gh = opts.guides ? geo.guides.h.slice(0) : [], gv = opts.guides ? geo.guides.v.slice(0) : [];
        if (opts.center) { gh.push(H / 2); gv.push(W / 2); }
        var seen = {};
        for (i = 0; i < gh.length; i++) if (gh[i] > 0 && gh[i] < H && !seen['h' + gh[i]]) { seen['h' + gh[i]] = 1; addGuide(gh[i], true); }
        for (i = 0; i < gv.length; i++) if (gv[i] > 0 && gv[i] < W && !seen['v' + gv[i]]) { seen['v' + gv[i]] = 1; addGuide(gv[i], false); }
    }

    try {
        doc.info.title = f.platform + ' ' + f.name;
        doc.info.caption = f.notes || '';
    } catch (e) {}
    doc.activeLayer = design;
}

var JOB = null;
$.global.__socialSafeZonesJob = function () {
    try { build(app.activeDocument, JOB.f, JOB.opts); } catch (e) { JOB.err = e; }
};

function createDocument(f, opts) {
    var name = f.platform.replace(/[()]/g, '') + ' - ' + f.name.replace(/[\/:]/g, '-') + ' (' + f.w + 'x' + f.h + ')';
    var fill = BACKGROUNDS[opts.bg] && !BACKGROUNDS[opts.bg].c ? DocumentFill.TRANSPARENT : DocumentFill.WHITE;
    var doc;
    try {
        doc = app.documents.add(new UnitValue(f.w, 'px'), new UnitValue(f.h, 'px'), 72, name, NewDocumentMode.RGB, fill, 1, BitsPerChannelType.EIGHT, 'sRGB IEC61966-2.1');
    } catch (e) {
        doc = app.documents.add(new UnitValue(f.w, 'px'), new UnitValue(f.h, 'px'), 72, name, NewDocumentMode.RGB, fill);
    }
    JOB = { f: f, opts: opts, err: null };
    doc.suspendHistory('Social Safe Zones', '__socialSafeZonesJob()');
    if (JOB.err) throw JOB.err;
    try { app.runMenuItem(c2t('FtOn')); } catch (e) {}
    return doc;
}

// =============================================================================
// 8. DIALOG
// =============================================================================

function gcd(a, b) { return b ? gcd(b, a % b) : a; }
function ratioText(w, h) {
    var g = gcd(w, h), a = w / g, b = h / g;
    return (a <= 32 && b <= 32) ? a + ':' + b : (w / h).toFixed(2) + ':1';
}
function sizeText(f) { return f.w + ' ' + TIMES + ' ' + f.h; }

function platforms() {
    var list = [], seen = {};
    for (var i = 0; i < LIB.length; i++) if (!seen[LIB[i].platform]) { seen[LIB[i].platform] = 1; list.push(LIB[i].platform); }
    return list;
}
function countFor(p) { var n = 0; for (var i = 0; i < LIB.length; i++) if (LIB[i].platform === p) n++; return n; }
function inArray(a, v) { for (var i = 0; i < a.length; i++) if (a[i] === v) return true; return false; }

function loadPrefs() {
    var p = { platform: 'Instagram', ids: ['instagram-story-1080x1920'], opts: { zones: true, labels: true, guides: true, center: false, mockup: true, opacity: 35, bg: 0 } };
    try {
        if (PREFS_FILE.exists) {
            PREFS_FILE.encoding = 'UTF-8';
            PREFS_FILE.open('r');
            var saved = eval(PREFS_FILE.read());
            PREFS_FILE.close();
            if (saved && saved.opts) p = saved;
        }
    } catch (e) {}
    return p;
}
function savePrefs(p) {
    try {
        PREFS_FILE.encoding = 'UTF-8';
        PREFS_FILE.open('w'); PREFS_FILE.write(p.toSource()); PREFS_FILE.close();
    } catch (e) {}
}

function showDialog(prefs) {
    var ALL = 'All formats', plats = platforms(), items = [ALL], i;
    for (i = 0; i < plats.length; i++) items.push(plats[i] + '  (' + countFor(plats[i]) + ')');

    var w = new Window('dialog', 'Social Safe Zones ' + VERSION);
    w.orientation = 'column'; w.alignChildren = ['fill', 'top']; w.spacing = 10; w.margins = 16;

    var main = w.add('group');
    main.orientation = 'row'; main.alignChildren = ['fill', 'fill']; main.spacing = 10;

    var p1 = main.add('panel', undefined, 'Platform');
    var platList = p1.add('listbox', undefined, items);
    platList.preferredSize = [190, 400];

    var p2 = main.add('panel', undefined, 'Format');
    p2.alignChildren = ['fill', 'top'];
    var sg = p2.add('group');
    sg.add('statictext', undefined, 'Search:');
    var search = sg.add('edittext', undefined, '');
    search.preferredSize.width = 250;
    var list = p2.add('listbox', undefined, [], { multiselect: true });
    list.preferredSize = [330, 340];
    p2.add('statictext', undefined, 'Cmd/Ctrl-click to select several \u2014 double-click to create.');

    var p3 = main.add('panel', undefined, 'Preview');
    p3.alignChildren = ['fill', 'top'];
    var canvas = p3.add('group');
    canvas.preferredSize = [260, 220];
    var title = p3.add('statictext', undefined, '');
    title.preferredSize = [260, 20];
    try { title.graphics.font = ScriptUI.newFont(title.graphics.font.name, 'BOLD', 13); } catch (e) {}
    var dims = p3.add('statictext', undefined, '');
    dims.preferredSize = [260, 18];
    var notes = p3.add('statictext', undefined, '', { multiline: true });
    notes.preferredSize = [260, 120];

    var op = w.add('panel', undefined, 'Options');
    op.orientation = 'row'; op.alignChildren = ['left', 'center']; op.spacing = 14;
    var cZones = op.add('checkbox', undefined, 'Safe-zone overlays');
    var cLabels = op.add('checkbox', undefined, 'Labels');
    var cGuides = op.add('checkbox', undefined, 'Guides');
    var cCenter = op.add('checkbox', undefined, 'Centre guides');
    var cMock = op.add('checkbox', undefined, 'UI mockup');
    var og = op.add('group');
    og.add('statictext', undefined, 'Opacity');
    var slider = og.add('slider', undefined, prefs.opts.opacity, 10, 80);
    slider.preferredSize.width = 100;
    var opTxt = og.add('statictext', undefined, prefs.opts.opacity + '%');
    opTxt.preferredSize.width = 36;
    var bgg = op.add('group');
    bgg.add('statictext', undefined, 'Background');
    var bgNames = [];
    for (i = 0; i < BACKGROUNDS.length; i++) bgNames.push(BACKGROUNDS[i].name);
    var bgList = bgg.add('dropdownlist', undefined, bgNames);
    bgList.selection = prefs.opts.bg || 0;

    cZones.value = prefs.opts.zones; cLabels.value = prefs.opts.labels; cGuides.value = prefs.opts.guides;
    cCenter.value = prefs.opts.center; cMock.value = prefs.opts.mockup;
    slider.onChanging = function () { opTxt.text = Math.round(slider.value) + '%'; };

    var btns = w.add('group');
    btns.alignment = ['fill', 'bottom'];
    var legend = btns.add('statictext', undefined, 'Red = covered by UI    Orange = may be covered    Blue = cropped    Green = safe area');
    legend.alignment = ['left', 'center'];
    var spacer = btns.add('group'); spacer.alignment = ['fill', 'center'];
    var cancel = btns.add('button', undefined, 'Cancel', { name: 'cancel' });
    var ok = btns.add('button', undefined, 'Create', { name: 'ok' });
    ok.preferredSize.width = 150;

    var current = null, selectedIds = prefs.ids || [], ready = false, filling = false;

    function selection() {
        var s = list.selection, out = [];
        if (!s) return out;
        if (!(s instanceof Array)) s = [s];
        for (var j = 0; j < s.length; j++) out.push(s[j].fmt);
        return out;
    }

    function refreshList() {
        var plat = platList.selection ? platList.selection.index : 0;
        var q = search.text.toLowerCase().replace(/^\s+|\s+$/g, ''), words = q ? q.split(/\s+/) : [];
        var showPlatform = plat === 0 || words.length > 0;
        filling = true; // rebuilding the list must not reset the remembered selection
        list.removeAll();
        var pre = [];
        for (var j = 0; j < LIB.length; j++) {
            var f = LIB[j];
            if (!words.length && plat > 0 && f.platform !== plats[plat - 1]) continue;
            var hay = (f.platform + ' ' + f.name + ' ' + f.w + 'x' + f.h + ' ' + (f.notes || '')).toLowerCase(), ok2 = true;
            for (var k = 0; k < words.length; k++) if (hay.indexOf(words[k]) < 0) { ok2 = false; break; }
            if (!ok2) continue;
            var it = list.add('item', (showPlatform ? f.platform + DOT : '') + f.name + '   \u2014   ' + sizeText(f));
            it.fmt = f;
            if (inArray(selectedIds, f.id)) pre.push(it.index);
        }
        // item.selected = true is ignored by multi-select lists on macOS; set the selection instead
        if (pre.length) {
            try { list.selection = pre; } catch (e) { list.selection = pre[0]; }
        }
        filling = false;
        updateSelection();
    }

    function updateSelection() {
        var sel = selection();
        current = sel.length ? sel[sel.length - 1] : null;
        ok.text = sel.length > 1 ? 'Create ' + sel.length + ' documents' : 'Create';
        ok.enabled = sel.length > 0;
        if (current) {
            title.text = current.platform + DOT + current.name;
            dims.text = sizeText(current) + ' px' + DOT + ratioText(current.w, current.h);
            notes.text = (current.notes || '').replace(/\s*&\s*/g, ' and '); // ScriptUI treats & as a mnemonic
        } else {
            title.text = ''; dims.text = ''; notes.text = 'Select a format.';
        }
        canvas.hide(); canvas.show(); // force redraw
    }

    canvas.onDraw = function () {
        try {
            var g = this.graphics, CW = this.size[0], CH = this.size[1], bgc = [0.17, 0.17, 0.17, 1];
            var fillR = function (x, y, ww, hh, col) { g.newPath(); g.rectPath(x, y, ww, hh); g.fillPath(g.newBrush(g.BrushType.SOLID_COLOR, col)); };
            fillR(0, 0, CW, CH, bgc);
            if (!current) return;
            var f = current, sc = Math.min((CW - 16) / f.w, (CH - 16) / f.h), ox = (CW - f.w * sc) / 2, oy = (CH - f.h * sc) / 2;
            var geo = resolve(f), j, z, col;
            var X = function (v) { return ox + v * sc; }, Y = function (v) { return oy + v * sc; };
            fillR(ox, oy, f.w * sc, f.h * sc, [1, 1, 1, 1]);
            var rgba = function (c, a) { return [c[0] / 255, c[1] / 255, c[2] / 255, a]; };
            for (j = 0; j < geo.fills.length; j++) {
                z = geo.fills[j];
                if (z.shape !== 'outside') continue;
                fillR(ox, oy, f.w * sc, f.h * sc, rgba(KIND[z.k].fill, 0.45));
                g.newPath(); g.ellipsePath(X(z.r[0]), Y(z.r[1]), z.r[2] * sc, z.r[3] * sc);
                g.fillPath(g.newBrush(g.BrushType.SOLID_COLOR, [1, 1, 1, 1]));
            }
            for (j = 0; j < geo.fills.length; j++) {
                z = geo.fills[j];
                if (z.shape === 'outside') continue;
                col = rgba(KIND[z.k].fill, z.k === 'crop' ? 0.3 : 0.45);
                g.newPath();
                if (z.shape === 'ellipse') g.ellipsePath(X(z.r[0]), Y(z.r[1]), z.r[2] * sc, z.r[3] * sc);
                else g.rectPath(X(z.r[0]), Y(z.r[1]), z.r[2] * sc, z.r[3] * sc);
                g.fillPath(g.newBrush(g.BrushType.SOLID_COLOR, col));
            }
            for (j = 0; j < geo.frames.length; j++) {
                z = geo.frames[j];
                g.newPath();
                if (z.shape === 'ellipse') g.ellipsePath(X(z.r[0]), Y(z.r[1]), z.r[2] * sc, z.r[3] * sc);
                else g.rectPath(X(z.r[0]), Y(z.r[1]), z.r[2] * sc, z.r[3] * sc);
                g.strokePath(g.newPen(g.PenType.SOLID_COLOR, rgba(KIND[z.k].fill, 1), z.k === 'safe' ? 2 : 1));
            }
            // mask anything drawn outside the canvas
            fillR(0, 0, CW, oy, bgc); fillR(0, oy + f.h * sc, CW, CH, bgc);
            fillR(0, 0, ox, CH, bgc); fillR(ox + f.w * sc, 0, CW, CH, bgc);
        } catch (e) {}
    };

    platList.onChange = function () {
        // keep the remembered selection while the dialog initialises
        if (ready) { selectedIds = []; for (var j = 0, s = selection(); j < s.length; j++) selectedIds.push(s[j].id); }
        refreshList();
    };
    search.onChanging = function () { refreshList(); };
    list.onChange = function () {
        if (filling) return;
        selectedIds = [];
        for (var j = 0, s = selection(); j < s.length; j++) selectedIds.push(s[j].id);
        updateSelection();
    };
    list.onDoubleClick = function () { if (selection().length) w.close(1); };

    var startIndex = 0;
    for (i = 0; i < plats.length; i++) if (plats[i] === prefs.platform) startIndex = i + 1;
    platList.selection = startIndex;
    refreshList();
    ready = true;
    search.active = true;

    if (w.show() !== 1) return null;
    var formats = selection();
    if (!formats.length) return null;
    var opts = {
        zones: cZones.value, labels: cLabels.value, guides: cGuides.value, center: cCenter.value, mockup: cMock.value,
        opacity: Math.round(slider.value), bg: bgList.selection ? bgList.selection.index : 0
    };
    var ids = [];
    for (i = 0; i < formats.length; i++) ids.push(formats[i].id);
    savePrefs({ platform: platList.selection && platList.selection.index > 0 ? plats[platList.selection.index - 1] : ALL, ids: ids, opts: opts });
    return { formats: formats, opts: opts };
}

// =============================================================================
// 9. MAIN
// =============================================================================

function main() {
    var test = $.global.SOCIAL_SAFE_ZONES_TEST, choice;
    if (test) {
        choice = { formats: [], opts: test.opts || loadPrefs().opts };
        for (var t = 0; t < LIB.length; t++) if (!test.ids || inArray(test.ids, LIB[t].id)) choice.formats.push(LIB[t]);
    } else {
        choice = showDialog(loadPrefs());
    }
    if (!choice) return;

    var saved = { ruler: app.preferences.rulerUnits, type: app.preferences.typeUnits, dialogs: app.displayDialogs };
    app.preferences.rulerUnits = Units.PIXELS;
    app.preferences.typeUnits = TypeUnits.PIXELS;
    app.displayDialogs = DialogModes.NO;
    var errors = [];
    try {
        for (var i = 0; i < choice.formats.length; i++) {
            try { createDocument(choice.formats[i], choice.opts); }
            catch (e) { errors.push(choice.formats[i].platform + ' ' + choice.formats[i].name + ': ' + e + (e.line ? ' (line ' + e.line + ')' : '')); }
        }
    } finally {
        app.preferences.rulerUnits = saved.ruler;
        app.preferences.typeUnits = saved.type;
        app.displayDialogs = saved.dialogs;
    }
    if (test) { $.global.SOCIAL_SAFE_ZONES_RESULT = errors; return; }
    if (errors.length) alert('Social Safe Zones \u2014 some formats failed:\n\n' + errors.join('\n'));
}

$.global.SOCIAL_SAFE_ZONES_LIB = LIB;
main();

})();
