diff --git a/docs/verification/pwa.md b/docs/verification/pwa.md
index 8328805..f509595 100644
--- a/docs/verification/pwa.md
+++ b/docs/verification/pwa.md
@@ -3,8 +3,7 @@
Use a Chromium browser for the install and service-worker checks. The development server does not register the production service worker, so build and preview the app first.
```sh
-npm run build
-npm run preview -- --host 127.0.0.1
+npm run pwa:preview
```
## Install
@@ -37,3 +36,19 @@ The menu action is intentionally absent when the browser does not expose an inst
7. Confirm the current project is saved before the waiting worker activates and version B loads.
To exercise the save-failure path, temporarily make IndexedDB unavailable before choosing **Restart**. Pixel Forge should remain open and explain that the current session stayed open.
+
+## Project file handling
+
+File associations are refreshed when the PWA is installed. After changing the manifest, uninstall Pixel Forge, run `npm run pwa:preview`, and install it again before testing.
+
+1. In Finder, use **Open With → Pixel Forge** for a `.pf` project.
+2. Repeat with `.ase` and `.aseprite` files.
+3. Keep Pixel Forge open and use **Open With → Pixel Forge** again. Confirm the existing app window is focused and each project opens in a new internal tab.
+4. Select multiple supported files in Finder and open them together. Confirm their tab order matches the selection order.
+5. Fill all eight project tabs, then open one more valid project file. Confirm Pixel Forge explains that the import was saved to **Project Library** without replacing the active drawing.
+6. Drag `.pf`, `.json`, `.ase`, and `.aseprite` files onto the app. Confirm they use the same new-project behavior and feedback.
+7. Drop an unsupported file by itself. Confirm Pixel Forge leaves the browser's normal behavior alone.
+8. Deny the browser-owned file-handling permission when prompted, then confirm the open project remains unchanged.
+9. Open Pixel Forge in Firefox or Safari and confirm the editor starts normally. File-menu import and drag-and-drop should still work; operating-system association is unavailable there.
+
+Pixel Forge reads only the files supplied for the current launch. It does not retain file handles, request write permission, or save changes back to the source file.
diff --git a/package.json b/package.json
index f63ead7..db6e13a 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
+ "pwa:preview": "npm run build && vite preview --host 127.0.0.1",
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui",
diff --git a/src/components/app/pf-project-tabs.ts b/src/components/app/pf-project-tabs.ts
index 511704f..50de6af 100644
--- a/src/components/app/pf-project-tabs.ts
+++ b/src/components/app/pf-project-tabs.ts
@@ -131,25 +131,77 @@ export class PFProjectTabs extends BaseComponent {
}
.error {
+ display: flex;
+ align-items: center;
+ gap: 8px;
padding: 5px 10px 7px;
color: #f0aaa2;
font-size: 12px;
}
+
+ .error button {
+ padding: 2px 6px;
+ border: 1px solid currentColor;
+ border-radius: var(--pf-radius-sm);
+ }
+
+ .visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ border: 0;
+ }
`;
@state() private errorMessage = '';
+ private feedbackTimer: number | null = null;
+
+ disconnectedCallback() {
+ this.dismissFeedback();
+ super.disconnectedCallback();
+ }
+
+ private showFeedback(message: string) {
+ this.dismissFeedback();
+ this.errorMessage = message;
+ this.feedbackTimer = window.setTimeout(() => {
+ this.errorMessage = '';
+ this.feedbackTimer = null;
+ }, 6000);
+ }
+
+ private dismissFeedback = () => {
+ if (this.feedbackTimer !== null) {
+ clearTimeout(this.feedbackTimer);
+ this.feedbackTimer = null;
+ }
+ this.errorMessage = '';
+ };
private activateItem(itemId: string) {
const result = workspaceStore.activate(itemId);
- this.errorMessage = result.ok ? '' : result.message;
+ if (result.ok) {
+ this.dismissFeedback();
+ } else {
+ this.showFeedback(result.message);
+ }
}
private async closeItem(itemId: string) {
try {
const result = await workspaceStore.closeProject(itemId);
- this.errorMessage = result.ok ? '' : result.message;
+ if (result.ok) {
+ this.dismissFeedback();
+ } else {
+ this.showFeedback(result.message);
+ }
} catch {
- this.errorMessage = 'Could not close project.';
+ this.showFeedback('Could not close project.');
}
}
@@ -168,11 +220,11 @@ export class PFProjectTabs extends BaseComponent {
private openProjectBrowser() {
if (workspaceStore.items.value.length >= WORKSPACE_OPEN_ITEM_LIMIT) {
- this.errorMessage = workspaceItemLimitMessage();
+ this.showFeedback(workspaceItemLimitMessage());
return;
}
- this.errorMessage = '';
+ this.dismissFeedback();
this.dispatchEvent(new CustomEvent('show-project-browser', { bubbles: true, composed: true }));
}
@@ -236,7 +288,17 @@ export class PFProjectTabs extends BaseComponent {
+
- ${this.errorMessage ? html`