# FrameStudio — complete owner’s guide

## 1. What this tool does

FrameStudio is your private branded-photo workshop. Add a transparent frame once, reuse it with any photo, edit its live text, and download a finished image. No account, subscription, backend, build step, or runtime CDN is needed.

```text
Your photo → position / crop / zoom ─┐
                                  ├→ browser canvas → PNG / JPG / WebP
Your saved frame → edit its text ──┘                    ├→ download / copy / share
                                                       └→ optional, explicit upload
```

Photos are processed on your device. FrameStudio does not send photos to a server unless you press **Upload & get link** and confirm. Web Share sends the exported image to the app you choose. Frame backups contain frames, embedded fonts, settings, and text—not your original photos or hosting credentials.

### First launch

1. Open FrameStudio on a local server or your deployed HTTPS site.
2. The supplied GTA6 frame and the first sample photo load automatically on a fresh browser profile.
3. Find the headline input in **Make it yours**. Replace the placeholder with `Hidden gas station secret in Vice City`.
4. Its styled first letter becomes **H**, including the separate glow layer. The rest retains its cream fill.
5. Drag the photo to reposition it. Export as PNG at 1× for a 1080 × 1080 image.
6. Use **Add photo** for your own photo, or **Sample photo** for the other included image.

### Decisions made for this project

- The actual supplied SVG has a top-left **PRO TIPS & GUIDES** text chip, a top-right outlined **GTA 6** badge, and the headline. It does **not** contain live text reading HIDDEN MECHANICS. The original artwork is preserved. Type HIDDEN MECHANICS in the category input if desired; use a manual box for outlined text you cannot edit.
- Every SVG text element is detected. The sample’s `first-letter-glow` is deliberately linked to `post-title-text` instead of asking you to maintain two identical letters.
- Poppins Bold (700) and ExtraBold (800) are bundled locally and attached to the built-in demo. They are SIL Open Font License fonts; the complete license is in `lib/Poppins-OFL.txt`. A separately uploaded raw SVG needs its own embedded/uploaded fonts. Use those bundled files in the Fonts panel if it is the sample.
- A missing preferred non-system font is not silently accepted for export. The preview warns; export waits for matching embedded or uploaded fonts. Generic sans-serif, serif, and monospace are intentional system-font choices, not portable brand-font matches.
- Multiline SVG text is fitted within the frame; the sample headline wraps upward so it does not collide with the footer below it. Width/minimum size/max lines can be adjusted per field.
- Photo positions are session-only. Frame configuration and your last text are saved. After reloading, add the photo again; original private photos are not saved in IndexedDB.
- Batch exports use centered cover independently for every photo and current text/frame settings. The first photo’s custom crop is not copied to unrelated aspect ratios.
- There is no numerical frame-library limit. Device quota, RAM, and archive size still impose practical limits. Large batches are sequentially rendered, but their encoded files must fit in memory while the ZIP is assembled.
- Output is limited to 64 megapixels and 16384 pixels per side as a browser-memory safety ceiling, not a promise that every device can allocate that much. Use 1× on older phones.
- Photo decoders honor image orientation using the browser’s EXIF-aware image APIs. Photos larger than 8000 px on either side are reduced to an 8000 px longest side; smaller ones are not downscaled internally. The preview is independently limited to a 1200 px longest side for responsiveness; full output is rendered again from the photo.
- SVGs are static graphics. Script, foreign HTML, external references, external fonts, and animation are removed for safety. Do not rely on external resources in a frame.
- The supplied SVG file itself is left intact. Its sanitized copy is what the app stores and renders.
- Hosting is optional. Free-service quotas and link lifetime are controlled by the provider and can change. Your own R2 bucket gives you more control; it is not guaranteed to be cost-free beyond your plan’s allowance.

## 2. Deploy on Cloudflare Pages

You upload a ready-made website. Do not run a build command.

### Method A — Direct Upload (easiest)

1. Make a copy of the project folder for deployment.
2. Check that `index.html`, `css`, `js`, `lib`, `icons`, `sample`, `manifest.json`, `sw.js`, `_headers`, and `GUIDES.md` are included. Do not omit the fonts or sample images: offline installation caches them.
3. Sign in to the Cloudflare dashboard.
4. Open **Workers & Pages** and select **Create application**, then the Pages **Upload assets** / **Direct Upload** option. Dashboard wording may differ slightly.
5. Give the project a name such as `framestudio`.
6. Drag the folder containing `index.html`, or a ZIP of its contents, into the uploader. `index.html` must be at the deployment root, not inside an extra parent folder.
7. Select **Deploy site**. Open the HTTPS address shown by Cloudflare.
8. Test the demo and a PNG download on that live address.

Keep `PROMPT.md` only if you are happy to publish the original brief. It is not needed by the app. A local testing report or screenshot also does not need to be uploaded.

### Method B — Git-connected Pages

GitHub is only an optional source-code deployment method, never an image hosting provider in this app.

1. Create a GitHub repository you control.
2. Upload the project files and folders with `index.html` at the repository root. Never commit provider keys, tokens, personal photos, or backups.
3. In Cloudflare **Workers & Pages**, create a Pages project and connect the repository.
4. Select the branch to deploy.
5. Choose **Framework preset: None**.
6. Leave **Build command** empty.
7. Set **Build output directory** to `/` (repository root). Leave the project root setting at the repository root as well.
8. Deploy and open the resulting site.

A Direct Upload project and a Git-connected project are different creation modes. If Cloudflare does not offer switching modes, create a new project rather than assuming the old one can be converted.

### Custom domain or subdomain

1. Open your Pages project → **Custom domains** → **Set up a custom domain**.
2. Enter your chosen domain or subdomain, such as `frames.your-domain.example`.
3. Follow Cloudflare’s DNS verification instructions. For a root domain, Cloudflare generally needs to manage that domain’s DNS; for a subdomain, use the CNAME shown by the dashboard when permitted.
4. Wait for certificate provisioning and the domain to become active.
5. Visit the new HTTPS address and import your frame backup. Browser storage belongs to each origin: the old Pages address and the custom domain have separate libraries.

### Update later

- Direct Upload: create a new deployment and upload the whole updated folder.
- Git-connected: commit/push your changes to the connected branch; Pages deploys them.
- Increment the `CACHE` name in `sw.js` whenever you release changed assets. Keep the complete `CORE` asset list accurate.
- Reload once online after an update. If the old app persists, close other tabs and reload again. As a last resort, use browser developer tools → Application → Service Workers → Unregister. Do not clear site storage without backing up frames.
- App-code updates do not intentionally delete the frame database. Always keep a backup before a major change.

## 3. Local use and installation

Double-clicking `index.html` uses a `file:` address and will not work reliably with ES modules, fetch, IndexedDB, or service workers. Use a static server.

### With Python

1. Install Python if needed.
2. Open a terminal in this project folder.
3. Run:

```sh
python -m http.server 8080
```

On some machines the command is `python3 -m http.server 8080` or `py -m http.server 8080`.
4. Open `http://localhost:8080` in your browser.
5. Leave the terminal open while using the site. Press Ctrl+C to stop the server.

### With Node.js

```sh
npx serve . -l 8080
```

`npx` may ask to download the server utility the first time. It is a development convenience, not a runtime app dependency.

### Install as an app

- **Android Chrome:** open the HTTPS site, tap the browser menu, then **Install app** or **Add to Home screen**. The app’s Install button appears when the browser provides the installation prompt.
- **iPhone/iPad Safari:** open the HTTPS site, tap **Share**, then **Add to Home Screen**. iOS does not provide the same automatic installation prompt as Chrome.
- **Desktop Chrome/Edge:** click the browser’s install icon or use **Install app** in its menu. You can also use FrameStudio’s Install button when available.
- **Other browsers:** normal browser-tab use is supported even if PWA installation is not.

Open the app once online and wait for its assets to finish loading before expecting offline use. Localhost is a secure-context exception, so most PWA/clipboard features work there. A phone opening a computer’s plain LAN HTTP address is not the same as localhost: install, clipboard, and storage features may be restricted. Prefer the deployed HTTPS site.

## 4. Prepare a good SVG or PNG frame

Think of the frame as a sheet of transparent glass placed over the photo.

- Use the frame’s final dimensions. For square posts, use a `viewBox="0 0 1080 1080"`.
- Leave the photo window transparent. A solid white rectangle in the SVG is not transparent and will cover the photo.
- Use real SVG `<text>` elements, not outlines/paths, for anything you want to edit.
- Give text elements helpful stable IDs, such as `headline`, `category`, and `credit`.
- Use explicit positions, font family, size, weight, and fill. Avoid text flowing through foreign HTML.
- Embed fonts or upload them inside FrameStudio. Make sure your font license permits embedding.
- Embed raster graphics as base64 data URLs; externally linked images are removed.

### Minimal compatible SVG

```xml
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1080 1080">
  <rect x="12" y="12" width="1056" height="1056"
        rx="35" fill="none" stroke="#ed9bdf" stroke-width="8"/>
  <rect x="0" y="870" width="1080" height="210" fill="#180b28"/>
  <text id="headline" x="55" y="985" fill="#fff3c4"
        font-family="sans-serif" font-size="54" font-weight="700">
    YOUR HEADLINE HERE
  </text>
</svg>
```

### Drop-cap pattern

```xml
<text id="headline" x="56" y="846" font-family="Poppins"
      font-weight="800" font-size="56" fill="#fff3c4">
  <tspan id="first-letter" font-size="78" fill="url(#headlineGradient)">S</tspan>AMPLE TEXT GOES HERE
</text>
```

Define `headlineGradient` inside the SVG’s `<defs>`. Avoid accidental indentation whitespace inside a one-letter span. The rest can be a plain text node (as in your supplied sample) or another styled span. A separate decorative first-letter glow in arbitrary artwork may need an explicit relationship; the supplied sample’s known ID is handled automatically.

### Fonts: why installing one on your computer is not enough

An SVG drawn into canvas is an isolated image. It cannot fetch Google Fonts, load a web stylesheet, or reliably use the page’s downloadable font. FrameStudio embeds uploaded font bytes directly into the SVG’s `@font-face` rules before image decoding, and loads the font for text measurement.

1. Select the frame.
2. Open **Fonts**.
3. Enter the exact family name from your SVG, for example `Poppins`.
4. Select the matching weight and style. The sample uses **700 Bold** for the category and **800 Extra bold** for the headline/glow.
5. Press **Upload font file**, select a TTF, OTF, WOFF, or WOFF2 file.
6. Repeat for the other weight/style as needed. Uploading a regular font and labeling it bold does not make it the real bold font.
7. Wait for the warning to clear, then export.

The app honors valid base64 font-face rules already present in an SVG. Generic system fonts can vary between devices. For reproducible branding, always embed the exact brand fonts.

### Export from design tools

- **Figma:** export SVG and disable any “outline text” option if your export workflow provides one. Inspect the result for `<text>`; some plugins/export paths convert text to paths regardless. If your export is outlined, recreate the editable fields as live text in Inkscape or use manual text boxes in FrameStudio.
- **Illustrator:** save/export as SVG, choose SVG text rather than “convert to outlines,” retain a viewBox, and embed linked images. Upload fonts separately if the output does not embed them.
- **Inkscape:** use ordinary text objects, not Object to Path. Avoid flowed text outside SVG-standard `<text>/<tspan>`. Save as Plain SVG and check that the text remains text.
- **Canva:** SVG export availability depends on your plan; exported text is often outlined. Use the export as artwork and add manual text boxes, or rebuild editable text in an SVG editor. There is no reliable automatic conversion from outlined letters back into editable text.

Complex per-letter positions, rotated text, text-on-path, CSS transforms, or bespoke multiline layouts should be checked visually after editing. The renderer is designed for normal text/tspan frame templates, not a complete replacement for a vector design application.

## 5. Manage your frame library

### Add frames

- Press the **+** beside Your frames and select SVG or PNG files.
- Select multiple files at once, or drag several onto the frame library.
- Drag photos onto the central preview instead. A PNG could be either a photo or a frame, so the drop location determines its role.
- An already-added identical file is skipped. Use **More → Duplicate frame** when you intentionally want a second editable version.
- PNG frames must have transparent pixels where the photo should show through. PNG cannot contain live SVG text.

### Edit and organize

1. Click a frame thumbnail to switch instantly. Your last text/settings are retained per frame.
2. Type in the detected text fields. The preview updates after a short typing delay.
3. Expand **Text options** for alignment, uppercase, maximum line count, minimum size, width, visibility, and an optional solid-color override.
4. Use **Reset to placeholder** to restore that field’s original text.
5. Use the star for favorites. Search by any part of a frame name.
6. Use **Rename** to give the frame a meaningful name.
7. Use **More** to duplicate, export, move earlier/later, or delete.
8. On desktop, drag a card onto another card to reorder. Move earlier/later also works without dragging, including on mobile.
9. A deletion asks for confirmation. There is no cloud copy unless you made a backup.

The compact mobile library scrolls horizontally. Backup and restore controls remain below the list on phones and larger screens.

### Backup and restore

- **Back up frames** downloads one ZIP containing the entire library.
- **More → Export this frame** downloads a one-frame ZIP using the same backup format.
- **Restore ZIP** imports either kind. Existing entries are merged without overwriting the current saved copy; duplicates are skipped according to the archive’s identity/content rules.
- Backups include frame files, names, text values/configuration, manual layers, uploaded fonts, dates, and settings. They do not contain original photos, hosting tokens, or the upload history.
- Keep dated backups outside the browser (for example, in your documents folder and a separate drive).
- ZIP is a container, not encryption. Treat a backup as private if its artwork or text is private.
- Archives use a manifest plus separate frame JSON entries, so a large library is not forced into one large manifest. Safety limits are 64 MB per encoded entry and 256 MB per ZIP, with up to 10,000 ZIP entries. These protect browser memory when opening untrusted files; the saved frame library itself has no count limit. For a library beyond the ZIP budget, use individual frame exports. SVG/PNG inputs are limited to 32 MB each and font uploads to 20 MB each.

## 6. Position photos and export

### Add a photo

Use **Add photo**, drag a file onto the preview, or paste an image with Ctrl+V (Cmd+V on macOS). Pasting while a text box is focused remains normal text paste. JPG, PNG, and WebP work in modern browsers. HEIC/HEIF works only if that browser/OS can decode it; otherwise convert it to JPG or PNG first.

### Positioning

- **Cover:** fills the whole photo area, cropping excess edges. This is the default.
- **Fit:** shows the whole photo inside the area; the remaining region shows the selected background.
- Drag the preview to reposition the photo.
- Scroll, pinch with two fingers, or use the zoom slider.
- **Center:** resets the position but keeps the zoom and fit mode.
- **Reset:** returns to centered 100% cover.
- Arrow keys nudge the photo one native pixel; Shift+arrow moves ten pixels. Shortcuts do not hijack text editing.
- You may deliberately drag beyond the photo edges; the background becomes visible. Reset returns to a filled centered composition.

### Partial photo window

1. Expand **Photo area & background**.
2. Enable the custom rectangle.
3. Enter Left (x), Top (y), Width, and Height in native frame pixels.
4. Pick a background color for everything outside it and for uncovered areas.
5. The frame is still drawn over the full canvas. These settings are remembered with the frame.

### Manual text boxes

1. Press **Draw a manual text box**.
2. Drag a rectangle on the preview. A new text control appears.
3. Type your text and expand **Box position & style**.
4. Set the exact x/y/width/height, font size, uploaded font family, weight, color or two-color gradient, alignment, uppercase, shadow, and visibility.
5. Choose **Move this box**, then drag it on the preview. Use the numeric width/height controls to resize.
6. Press Escape or Center to stop moving the box and return to photo positioning.
7. Text is clipped to its box; an overflow warning asks you to enlarge it or reduce the size. The dashed selection outline is editor-only and never exported.

A manual box goes on top of the frame. It cannot magically erase outlined lettering already inside a PNG/SVG; prepare a blank version of that artwork when replacing outlined text.

### Output controls

- **PNG:** lossless, sharp graphic edges, larger files. Default.
- **JPG:** smaller photo-friendly files; use the quality slider. The chosen background is included (no transparency).
- **WebP:** efficient modern format. If the browser cannot encode it, choose PNG; the app will not silently save mislabeled PNG bytes as WebP.
- **1×:** native frame dimensions. The sample is 1080 × 1080.
- **1.5×:** sample becomes 1620 × 1620.
- **2×:** sample becomes 2160 × 2160.
- **3×:** sample becomes 3240 × 3240.

SVG artwork is decoded again at export resolution. Enlarging a raster PNG frame or a low-resolution source photo cannot invent extra detail.

- **Download image** saves the selected format. Ctrl+S/Cmd+S also exports.
- **Copy image** copies PNG regardless of selected output format, for clipboard compatibility. Requires a supported browser and a secure context.
- **Share** opens the browser/OS share sheet if file sharing is supported. Some browsers may require a fresh user activation; use Download if sharing is unavailable or denied.

### Batch mode

1. Select/drop multiple photos onto the photo area.
2. The first is previewed, and the batch bar shows the count.
3. Choose the frame and edit the text. Every batch image gets that same current text.
4. Choose format, quality, and scale.
5. Press **Download batch ZIP**. Each source gets its own centered cover crop.
6. If one file fails, successful files still download with an `errors.txt` report. If all fail, the app shows an error rather than returning an empty ZIP.
7. **Clear batch** removes the queue but leaves the current preview.

## 7. Optional image hosting and hotlinks

Nothing is uploaded by ordinary editing or downloading.

### ImgBB

1. Create/sign in to an ImgBB account and obtain an API key from its API page.
2. Open FrameStudio **Settings**.
3. Choose **ImgBB** and paste the key into **ImgBB API key**.
4. Save settings.
5. Compose the image and press **Upload & get link**.
6. Read the confirmation and proceed only if you want that finished image sent to ImgBB.
7. Copy the Direct URL, HTML `<img>` tag, Markdown, or BBCode from the result dialog.

The key is stored only in this origin’s localStorage. It is not encrypted against someone who controls your browser/device. Do not use a privileged key on a shared device. ImgBB retention, limits, moderation, and availability are outside FrameStudio’s control.

### Custom endpoint contract

The app sends a multipart POST with the exported file in field `image`. If configured, it sends `Authorization: Bearer YOUR_TOKEN`. The endpoint must allow CORS from your site and return JSON. Set the JSON path to the direct image URL (for example `url` or `data.url`). Use HTTPS outside localhost. The direct image itself must be publicly readable if you want a hotlink.

### Optional Cloudflare Worker + R2: exact example

This is optional infrastructure you deploy yourself. It is **not** part of the static app and is not required for any local feature.

1. In Cloudflare, create an R2 bucket for public exported images. Review billing/free allowances first.
2. Configure an R2 custom domain for public reads, for example `images.your-domain.example`. Prefer a custom domain over the development `r2.dev` address for production.
3. Create a Worker using JavaScript ES module syntax.
4. In Worker settings → bindings, bind that R2 bucket as **IMAGES**.
5. Add plain-text environment variables:
   - `ALLOWED_ORIGIN`: your exact FrameStudio origin, without trailing slash, such as `https://frames.your-domain.example`.
   - `PUBLIC_BASE_URL`: your public R2 origin, without trailing slash, such as `https://images.your-domain.example`.
6. Add a **secret** named `UPLOAD_TOKEN`. Generate a long random value in a password manager, ideally at least 32 random bytes. Do not put it into the Worker source or commit it to Git.
7. Paste this complete Worker code and deploy:

```js
const MAX_BYTES = 15 * 1024 * 1024;

export default {
  async fetch(request, env) {
    const origin = request.headers.get('Origin');
    const cors = {
      'Access-Control-Allow-Origin': env.ALLOWED_ORIGIN,
      'Access-Control-Allow-Methods': 'POST, OPTIONS',
      'Access-Control-Allow-Headers': 'Authorization, Content-Type',
      'Access-Control-Max-Age': '86400',
      'Vary': 'Origin',
      'Cache-Control': 'no-store'
    };
    const reply = (body, status = 200) => new Response(
      JSON.stringify(body),
      { status, headers: { ...cors, 'Content-Type': 'application/json' } }
    );
    if (!env.ALLOWED_ORIGIN || !env.PUBLIC_BASE_URL || !env.UPLOAD_TOKEN) {
      return reply({ error: 'Server configuration is incomplete' }, 500);
    }
    if (origin !== env.ALLOWED_ORIGIN) {
      return new Response('Origin not allowed', { status: 403 });
    }
    if (request.method === 'OPTIONS') {
      return new Response(null, { status: 204, headers: cors });
    }
    if (request.method !== 'POST') {
      return reply({ error: 'Use POST' }, 405);
    }
    if (request.headers.get('Authorization') !== `Bearer ${env.UPLOAD_TOKEN}`) {
      return reply({ error: 'Unauthorized' }, 401);
    }
    const contentLength = Number(request.headers.get('Content-Length') || 0);
    if (contentLength > MAX_BYTES + 65536) {
      return reply({ error: 'Image too large; maximum 15 MB' }, 413);
    }
    if (!request.headers.get('Content-Type')?.startsWith('multipart/form-data')) {
      return reply({ error: 'Use multipart/form-data' }, 415);
    }
    try {
      // Count streamed bytes too: Content-Length can be missing or misleading.
      const reader = request.body?.getReader();
      if (!reader) return reply({ error: 'Missing body' }, 400);
      const chunks = [];
      let total = 0;
      for (;;) {
        const { done, value } = await reader.read();
        if (done) break;
        total += value.byteLength;
        if (total > MAX_BYTES + 65536) {
          await reader.cancel();
          return reply({ error: 'Image too large; maximum 15 MB' }, 413);
        }
        chunks.push(value);
      }
      const data = await new Response(new Blob(chunks), {
        headers: { 'Content-Type': request.headers.get('Content-Type') }
      }).formData();
      const image = data.get('image');
      if (!(image instanceof File) || image.size < 1 || image.size > MAX_BYTES) {
        return reply({ error: 'Provide an image file up to 15 MB' }, 400);
      }
      const bytes = new Uint8Array(await image.arrayBuffer());
      const png = [137,80,78,71,13,10,26,10].every((b, i) => bytes[i] === b);
      const jpg = bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255;
      const webp = new TextDecoder().decode(bytes.slice(0,4)) === 'RIFF' &&
                   new TextDecoder().decode(bytes.slice(8,12)) === 'WEBP';
      const extension = png ? 'png' : jpg ? 'jpg' : webp ? 'webp' : null;
      if (!extension) return reply({ error: 'Only PNG, JPG, WebP are accepted' }, 415);
      const contentType = {png:'image/png',jpg:'image/jpeg',webp:'image/webp'}[extension];
      if (image.type !== contentType) return reply({ error: 'Image type mismatch' }, 415);
      const key = `${new Date().toISOString().slice(0,10)}/${crypto.randomUUID()}.${extension}`;
      await env.IMAGES.put(key, bytes, {
        httpMetadata: { contentType, cacheControl: 'public, max-age=31536000, immutable' }
      });
      return reply({ url: `${env.PUBLIC_BASE_URL.replace(/\/$/, '')}/${key}` });
    } catch {
      return reply({ error: 'Upload could not be completed' }, 500);
    }
  }
};
```

8. Open FrameStudio Settings. Choose **Custom endpoint**.
9. Set the endpoint to the exact HTTPS Worker address shown after deployment.
10. Set **JSON path** to `url`.
11. Enter your secret value in **Bearer token** and save.
12. Upload a test image. Open the returned direct URL in a private window to confirm public reads work.

Security notes: CORS is not authentication; the bearer token is the authentication mechanism. A token stored in a browser is suitable only for a personally controlled tool—not a public upload service with a shared secret distributed to visitors. For public/multi-user hosting, use per-user authentication, server-side authorization, rate limits, usage caps, and content validation/scanning appropriate to your audience. This personal endpoint accepts only raster signatures and never accepts arbitrary SVG/HTML. If a token is exposed, rotate the Worker secret and update your browser settings. Public objects remain public until you remove them from R2. Set Cloudflare request/rate controls and billing alerts before wider use.

### Upload history

Settings lists each upload’s local thumbnail, direct link, and date. **Links** reopens all copy formats. **Remove** deletes only the local history entry; it does not call ImgBB or delete an R2 object. Delete hosted images in the provider dashboard if necessary.

## 8. Browser storage and privacy

- **IndexedDB:** frame sources, thumbnails, fonts, text configuration, manual text, dates, and upload history.
- **localStorage:** application defaults, last selected frame, initial-demo flag, and optional provider keys/token.
- **Service worker cache:** public app files and included sample/demo assets. User photos, exports, upload requests/responses, and third-party URLs are not cached by the service worker.
- **Memory only:** current user photo and batch source files, canvas previews, current positioning.

Storage is tied to the exact scheme + hostname + port. Changing from localhost:8080 to another port creates a different library. A custom domain has a different library from the Pages preview address. Browser profiles also have separate storage.

**Request persistent storage** asks the browser not to evict this site’s saved library under routine storage pressure. The browser may refuse; it is not a substitute for backups. Private browsing, clearing browsing data, uninstalling a browser, storage pressure, or device loss can remove data. Some browser privacy policies remove inactive site storage. Export regular ZIP backups and test restoring one.

If storage is full, back up first, remove unneeded duplicate frames, and reduce large PNG/font files before re-adding. The app shows storage/save errors rather than claiming a failed save succeeded.

**Clear all local data** removes the app’s saved library, history, settings, and keys after confirmation. It does not delete uploaded images from hosting providers. Cached application assets remain so the app can still open offline. The demo does not automatically repopulate a deliberately cleared library; **Try the GTA6 demo** remains available.

## 9. Troubleshooting

### Fonts look different / export is paused

Open Fonts and check the exact family, weight, and style. Installing a font on the operating system is not enough for a portable SVG image. Upload matching files for every used weight/style. The built-in demo includes both sample weights; a raw re-upload of the SVG does not automatically claim that font configuration. If a font is corrupt or mislabeled, use a fresh licensed copy. Full Unicode coverage depends on your actual font; missing glyphs may require a font that includes that writing system.

### Text overflows or becomes too small

Reduce the wording, increase Max width without covering other artwork, permit a second line, or lower Minimum size. The app first shrinks text and then wraps. A warning means the content still does not fit the configured region. A manual text box additionally has a fixed height: increase it or reduce font size. Alignment does not move surrounding badge artwork; a very long category cannot automatically redesign the chip.

### Blurry output

The in-browser preview is intentionally smaller than full export resolution. Inspect the downloaded file. Use 1× or higher and a sufficiently detailed source photo. SVG stays crisp, but PNG artwork and photographs cannot gain detail by upscaling. JPEG low quality can add halos around text; use PNG for the sharpest lettering.

### HEIC will not load

HEIC support is a browser/operating-system codec capability, not a JavaScript guarantee. Convert the photo to JPG/PNG first. There is no third-party conversion upload and no heavy bundled HEIC decoder.

### Huge photo, iOS Safari, blank export, memory error

Try 1×, close other heavy tabs, and use a smaller batch. The app reduces photos only when the longest side exceeds 8000 px, but initial decoding can still need substantial RAM. Device canvas limits may be lower than the app’s safety ceiling. A 3× 1080-square export is about 10.5 million pixels before temporary render surfaces and SVG filters. Try less complex frames if filters consume too much memory.

### SVG will not load or some objects disappear

Check that it is valid XML with a nonzero native size/viewBox. External images, external CSS/fonts, scripts, foreign HTML, and animation are intentionally not supported. Embed image/font resources. Convert incompatible effects into safe paths/raster art, but leave text you want to edit as text. Remove document-wide CSS rules intended for an HTML page. Use plain SVG, not a file merely renamed `.svg`.

### Save error / quota exceeded

Export a backup before removing anything. Check browser storage permissions and free disk space. Avoid private browsing for a long-term library. A pending save is shown in the studio header; do not close the tab until it says Saved on device. Font-heavy frames and high-resolution PNGs can use substantial space.

### Clipboard, share, install, or offline features unavailable

Use HTTPS or localhost. Browser permissions and feature support vary. Download works even when image clipboard or Web Share does not. On iOS use Safari’s Add to Home Screen menu. Wait for the initial offline cache to install while online. A missing file in `sw.js`’s core list causes offline installation to fail; verify all files were uploaded.

### Upload fails

Check the key/token, provider choice, endpoint spelling, returned JSON path, CORS origin, API limits, and maximum upload size. Your deployed custom domain must match the Worker’s `ALLOWED_ORIGIN` exactly. Requests from localhost need an explicitly allowed development origin on the server; do not weaken authentication. An HTTPS page cannot post to an insecure remote HTTP endpoint. If the image uploaded but the returned response failed, check your provider before repeating the upload.

### Backup does not restore / duplicate skipped

Use a FrameStudio ZIP backup, not an unrelated photo batch ZIP. The importer validates the archive and data. Existing entries are not blindly overwritten. Import into an empty browser profile to inspect the backup independently. Corrupt archives, excessive decompressed sizes, invalid fonts, and invalid SVG content may be rejected.

## 10. Project structure and extension

```text
index.html                 Accessible static application layout
css/style.css              Responsive midnight/daylight styling
js/app.js                  App state, interactions, library UI, settings
js/ui.js                   DOM controls, toasts, downloads
js/db.js                   IndexedDB records and storage reporting
js/frames.js               Frame ingestion, validation, backup/restore
js/svgText.js              SVG sanitization, detection and live text layout
js/fonts.js                Embedded/uploaded fonts and missing-font checks
js/compositor.js           Photo decoding, preview and full-size canvas export
js/upload.js               Optional ImgBB/custom upload and link formats
lib/zip.js                 Local ZIP implementation; no runtime CDN
lib/Poppins-Bold.ttf        Offline demo font (weight 700)
lib/Poppins-ExtraBold.ttf   Offline demo font (weight 800)
lib/Poppins-OFL.txt         Complete font redistribution license
manifest.json              PWA identity, install icons and start URL
sw.js                      Same-origin public asset offline cache
icons/                     SVG monogram plus 192/512 px PNG install icons
_headers                   Cloudflare Pages caching and security policy
sample/                    Original supplied SVG and two test photos
GUIDES.md                  This guide
PROMPT.md                  Original build brief; not needed at runtime
```

### Extend the app

- Add new output controls in `index.html` and wire them through `app.js` / `compositor.js`.
- Add a provider in `upload.js`; keep tokens user-configured, never hard-coded. Update the settings form and document the destination clearly.
- Change brand colors in `css/style.css` and update icon/manifest colors.
- If you change the saved record shape, add a deliberate database/backup migration, rather than assuming old libraries can be discarded.
- Keep user-supplied strings out of `innerHTML`; the UI uses textContent/DOM construction.
- Keep SVG sanitization at both file-import and archive-import boundaries.
- Add each new runtime asset to the service worker cache list and change its version on release.
- `_headers` permits HTTPS connections for optional user-configured upload destinations. It does not enable automatic uploads. Tighten `connect-src` to your own provider hosts if you deploy a restricted version.

### Repeatable checks

With Node.js installed, check each script (including `sw.js`) using `node --check path/to/file.js`. From a Unix-compatible terminal:

```sh
for file in js/*.js lib/*.js sw.js; do node --check "$file" || exit 1; done
```

Then serve the project locally and check:

1. First launch: sample and both required font weights load without missing-font warnings.
2. Edit the headline to Hidden gas station secret in Vice City; confirm the gradient H and glow agree.
3. Use a 4000 × 3000 landscape photo, drag/zoom/reset, and export PNG at 1×; inspect 1080 × 1080 output.
4. Try portrait and panoramic photos, fit mode, a partial photo area, and a manual box.
5. Try PNG frames, invalid SVG, unsupported photo data, and long text.
6. Rename/duplicate/favorite/reorder frames; reload and confirm saved text and last selection.
7. Export/restore a library ZIP twice; confirm the second restore does not add duplicates.
8. Batch two photos; inspect the ZIP entries and actual image sizes.
9. Try JPG/WebP, image clipboard, and sharing on supported browsers.
10. Inspect browser console/network for unexpected errors or requests. Editing must not send private images anywhere.
11. Load once online, then disconnect network and reload to check cached assets. Test the deployed HTTPS site separately because a generic local static server does not apply Cloudflare `_headers`.
12. Configure a real provider only when ready; uploads require credentials and deliberately publish an image, so a local smoke test should not automatically perform one.

### Verification performed for this delivery

- All 10 project JavaScript files were checked with Node 24.22.1 `--check`. The JavaScript Worker example in this guide was separately syntax-checked.
- The app was served at `http://127.0.0.1:8080` and exercised in a Chromium-based browser, including a final check with the production Content Security Policy applied.
- The supplied SVG was the main frame. The requested headline produced a gradient H and synchronized H glow with no missing-font warning.
- A 4000 × 3000 landscape test photo exported as PNG 1080 × 1080, JPG 1620 × 1620, WebP 2160 × 2160, and PNG 3240 × 3240.
- Browser checks covered pointer repositioning, manual-box creation/editing/removal, uploaded-font aliases for manual text, two-line wrapping, overflow warnings, corrupt-image rejection, transparent-PNG compositing, and missing-font/missing-weight detection.
- The actual batch UI rendered both supplied JPGs into a ZIP with two 1080 × 1080 PNG files.
- A 55-frame archive round trip restored all 55; restoring it again added zero duplicates. An edited variant was preserved without overwriting the existing frame. Font data survived backup/restore.
- Browser layout checks at widths 390, 768, 1280, and 1600 found no horizontal page overflow. A mobile screenshot was inspected and saved as `sample/framestudio-mobile-check.png` (a test artifact, not required at runtime).
- The local server was stopped, the app reloaded through its service worker cache, and a sample photo was loaded and exported successfully while the server remained stopped. The server was then restarted.
- Sanitizer checks removed scripts, event handlers, foreign HTML, and external URLs. Normal startup requested no external origins. Custom uploads were tested with a mocked response, including multipart image data, bearer authentication, nested JSON lookup, and escaped copy formats; no real image was published.
- Real ImgBB/R2 credentials, live deployment, physical-device iOS/Android installation, native share destinations, storage-quota exhaustion, and every possible font/script combination were not available for end-to-end testing. Browser-dependent features show actionable messages rather than promising support everywhere.
