Compare commits

..

1 Commits

Author SHA1 Message Date
renovate-bot 5a579f2860 chore(deps): update github/codeql-action action to v4
Security / scan (pull_request) Failing after 23s
2026-05-10 12:33:15 +00:00
28 changed files with 454 additions and 2259 deletions
-123
View File
@@ -1,123 +0,0 @@
name: Release
# Wird bei einem vX.Y.Z-Tag-Push ausgeloest. Baut die drei Web-Extension-ZIPs
# (Chrome/Firefox/Opera) und haengt sie ans passende Gitea-Release.
#
# Portiert von GitHub Actions auf Gitea Actions (2026-06): der fruehere
# softprops/action-gh-release-Step ist GitHub-spezifisch und laeuft auf Gitea
# nicht. Ersetzt durch die Gitea-native release-action (volle gitea.com-URL,
# da DEFAULT_ACTIONS_URL=github nackte Namen sonst von github.com zieht).
# Muster uebernommen aus HellionChat/.gitea/workflows/release.yml.
on:
push:
tags:
- 'v*'
# Manueller Recovery-Trigger: in Gitea "Run workflow" und den Tag (z.B. v2.2.0)
# im Ref-Dropdown waehlen, NICHT master. Der Validate-Step unten failt hart
# bei einem Nicht-Tag-Ref, weil die release-action GITHUB_REF direkt liest.
workflow_dispatch:
permissions:
contents: write
jobs:
build-release:
name: Build & Release
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
# release-action liest GITHUB_REF direkt (kein tag_name-Input). Vorab
# validieren, damit manuelle Dispatches von einem Branch-Ref hier laut
# scheitern statt nach einem vollen Build.
- name: Validate tag ref
run: |
if [[ "${GITHUB_REF}" != refs/tags/v* ]]; then
echo "::error::Release-Workflow muss auf einem v*-Tag laufen, got ${GITHUB_REF}"
echo "::error::Tag pushen, oder im workflow_dispatch-Ref-Dropdown den Tag (nicht master) waehlen."
exit 1
fi
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Extract version from tag
id: version
run: echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Create Chrome/Edge ZIP (Manifest V3)
run: |
mkdir -p dist
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-chrome.zip" \
manifest.json newtab.html src/js/*.js src/css/ assets/ _locales/ \
-x "*.git*" "dist/*" ".github/*" ".gitea/*" "src/js/opera/*"
- name: Create Firefox ZIP (Manifest V3)
run: |
cp manifest.json manifest.chrome-backup.json
cp manifest.firefox.json manifest.json
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-firefox.zip" \
manifest.json newtab.html src/js/*.js src/css/ assets/ _locales/ \
-x "*.git*" "dist/*" ".github/*" ".gitea/*" "manifest.chrome-backup.json" "manifest.firefox.json" "src/js/opera/*"
mv manifest.chrome-backup.json manifest.json
- name: Create Opera/Opera GX ZIP (Manifest V3 + workaround)
run: |
cp manifest.json manifest.chrome-backup.json
cp manifest.opera.json manifest.json
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-opera.zip" \
manifest.json newtab.html src/js/*.js src/js/opera/ src/css/ assets/ _locales/ \
-x "*.git*" "dist/*" ".github/*" ".gitea/*" "manifest.chrome-backup.json" "manifest.opera.json"
mv manifest.chrome-backup.json manifest.json
- name: Generate SHA256 checksums
run: |
cd dist
sha256sum *.zip > checksums-sha256.txt
cat checksums-sha256.txt
# Release per Gitea-API (curl), NICHT via gitea.com/actions/release-action: die ist `using: go`
# und stirbt auf diesem Runner mit exit 127 ("go not found"), weil act_runner v0.6.1 die go-Action
# weder im Job-Image noch im Runner kompiliert bekommt. curl + python3 sind im Job-Image vorhanden
# und laufen als normaler Step -> unabhaengig von go-Toolchain, Action-Cache und @main-Drift.
# GITHUB_API_URL/GITHUB_REPOSITORY/GITHUB_TOKEN injiziert Gitea Actions automatisch.
- name: Create release & upload assets (Gitea API)
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.version.outputs.tag }}
run: |
set -euo pipefail
API="${GITHUB_API_URL:-https://gitea.hellion-forge.cloud/api/v1}"
REPO="${GITHUB_REPOSITORY}"
AUTH="Authorization: token ${GITEA_TOKEN}"
# Release-Request-JSON (Body inkl. Installationshinweise) als python-Einzeiler bauen
# (mehrzeilig wuerde den YAML-run-Block brechen: Zeilen auf Spalte 0).
REQ=$(python3 -c 'import json,os; t=os.environ["TAG"]; body="## Hellion NewTab "+t+"\n\n### Installation\n- **Chrome / Edge / Brave / Vivaldi:** `hellion-newtab-"+t+"-chrome.zip`\n- **Firefox:** `hellion-newtab-"+t+"-firefox.zip`\n- **Opera / Opera GX:** `hellion-newtab-"+t+"-opera.zip`\n\nVollstaendige Installationsanleitung siehe README.\n\n### Checksums\n`checksums-sha256.txt` zum Verifizieren der Dateiintegritaet."; print(json.dumps({"tag_name": t, "name": "Hellion NewTab "+t, "body": body}))')
# Idempotent: existierendes Release zum Tag wiederverwenden, sonst anlegen.
REL_ID=$(curl -sf -H "$AUTH" "$API/repos/$REPO/releases/tags/$TAG" \
| python3 -c 'import sys,json;print(json.load(sys.stdin).get("id",""))' 2>/dev/null || true)
if [ -z "$REL_ID" ]; then
REL_ID=$(printf '%s' "$REQ" \
| curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" -d @- "$API/repos/$REPO/releases" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
echo "Release angelegt: $REL_ID"
else
echo "Release existiert bereits, wiederverwenden: $REL_ID"
fi
# Vorhandene gleichnamige Assets entfernen (idempotent bei Re-Runs), dann hochladen.
EXIST=$(curl -sf -H "$AUTH" "$API/repos/$REPO/releases/$REL_ID/assets" 2>/dev/null || echo '[]')
for f in dist/hellion-newtab-$TAG-chrome.zip dist/hellion-newtab-$TAG-firefox.zip dist/hellion-newtab-$TAG-opera.zip dist/checksums-sha256.txt; do
name=$(basename "$f")
aid=$(printf '%s' "$EXIST" | NAME="$name" python3 -c 'import sys,json,os;n=os.environ["NAME"];print(next((a["id"] for a in json.load(sys.stdin) if a.get("name")==n), ""))' 2>/dev/null || true)
if [ -n "$aid" ]; then
echo "ersetze vorhandenes Asset $name (id $aid)"
curl -sf -X DELETE -H "$AUTH" "$API/repos/$REPO/releases/$REL_ID/assets/$aid" >/dev/null || true
fi
echo "Upload $name ..."
curl -sf -X POST -H "$AUTH" -F "attachment=@$f" "$API/repos/$REPO/releases/$REL_ID/assets?name=$name" >/dev/null
done
echo "Release $TAG fertig: alle Assets hochgeladen."
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@v4
- name: Projektstruktur prüfen
run: |
@@ -45,11 +45,6 @@ jobs:
assert m.get('name'), 'Chrome: Name fehlt'
assert m.get('version'), 'Chrome: Version fehlt'
assert 'storage' in m.get('permissions', []), 'Chrome: Storage Permission fehlt'
assert 'activeTab' in m.get('permissions', []), 'Chrome: activeTab Permission fehlt (Quick Save v2.3)'
assert 'background' in m, 'Chrome: background-Key fehlt (Service Worker v2.3)'
assert 'service_worker' in m.get('background', {}), 'Chrome: background.service_worker fehlt'
assert isinstance(m.get('commands'), dict) and 'quick-save' in m['commands'], 'Chrome: commands.quick-save fehlt (Quick Save v2.3)'
assert 'action' in m, 'Chrome: action-Key fehlt (Badge-Bestätigung v2.3)'
print('manifest.json (V3) OK — Version:', m['version'])
with open('manifest.firefox.json') as f:
@@ -57,11 +52,6 @@ jobs:
assert mf.get('manifest_version') == 3, 'Firefox: Manifest V3 erwartet'
assert mf['version'] == m['version'], 'Firefox: Version stimmt nicht mit Chrome überein!'
assert 'browser_specific_settings' in mf, 'Firefox: browser_specific_settings fehlt'
assert 'activeTab' in mf.get('permissions', []), 'Firefox: activeTab Permission fehlt (Quick Save v2.3)'
assert 'background' in mf, 'Firefox: background-Key fehlt (Event-Page v2.3)'
assert 'scripts' in mf.get('background', {}), 'Firefox: background.scripts fehlt (Event-Page, kein service_worker)'
assert isinstance(mf.get('commands'), dict) and 'quick-save' in mf['commands'], 'Firefox: commands.quick-save fehlt (Quick Save v2.3)'
assert 'action' in mf, 'Firefox: action-Key fehlt (Badge-Bestätigung v2.3)'
print('manifest.firefox.json (V3) OK — Version:', mf['version'])
with open('manifest.opera.json') as f:
+76
View File
@@ -0,0 +1,76 @@
# Release — creates ZIP packages for Chrome, Firefox and Opera on new tag
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
build-release:
name: Build & Release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Extract version from tag
id: version
run: echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Create Chrome/Edge ZIP (Manifest V3)
run: |
mkdir -p dist
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-chrome.zip" \
manifest.json newtab.html src/js/*.js src/css/ assets/ _locales/ \
-x "*.git*" "dist/*" ".github/*" "src/js/opera/*"
- name: Create Firefox ZIP (Manifest V3)
run: |
cp manifest.json manifest.chrome-backup.json
cp manifest.firefox.json manifest.json
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-firefox.zip" \
manifest.json newtab.html src/js/*.js src/css/ assets/ _locales/ \
-x "*.git*" "dist/*" ".github/*" "manifest.chrome-backup.json" "manifest.firefox.json" "src/js/opera/*"
mv manifest.chrome-backup.json manifest.json
- name: Create Opera/Opera GX ZIP (Manifest V3 + workaround)
run: |
cp manifest.json manifest.chrome-backup.json
cp manifest.opera.json manifest.json
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-opera.zip" \
manifest.json newtab.html src/js/*.js src/js/opera/ src/css/ assets/ _locales/ \
-x "*.git*" "dist/*" ".github/*" "manifest.chrome-backup.json" "manifest.opera.json"
mv manifest.chrome-backup.json manifest.json
- name: Generate SHA256 checksums
run: |
cd dist
sha256sum *.zip > checksums-sha256.txt
cat checksums-sha256.txt
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: "Hellion NewTab ${{ steps.version.outputs.tag }}"
body: |
## Hellion NewTab ${{ steps.version.outputs.tag }}
### Installation
- **Chrome / Edge / Brave / Vivaldi:** `hellion-newtab-${{ steps.version.outputs.tag }}-chrome.zip`
- **Firefox:** `hellion-newtab-${{ steps.version.outputs.tag }}-firefox.zip`
- **Opera / Opera GX:** `hellion-newtab-${{ steps.version.outputs.tag }}-opera.zip`
See [README](README.md) for the full installation instructions.
### Checksums
See `checksums-sha256.txt` to verify file integrity.
files: |
dist/hellion-newtab-${{ steps.version.outputs.tag }}-chrome.zip
dist/hellion-newtab-${{ steps.version.outputs.tag }}-firefox.zip
dist/hellion-newtab-${{ steps.version.outputs.tag }}-opera.zip
dist/checksums-sha256.txt
generate_release_notes: true
+42
View File
@@ -0,0 +1,42 @@
# Sicherheitsprüfung — läuft bei Push und PR auf main/master
name: Security Scan
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
schedule:
# Wöchentlich Montag 06:00 UTC
- cron: '0 6 * * 1'
permissions:
contents: read
security-events: write
jobs:
codeql:
name: CodeQL Analysis
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
with:
languages: javascript
- name: Run CodeQL Analysis
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
dependency-review:
name: Dependency Review
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Dependency Review
uses: actions/dependency-review-action@v4
-44
View File
@@ -6,50 +6,6 @@ All notable changes per version. Format based on [Keep a Changelog](https://keep
---
## [2.4.0] — 2026-06-15
### Added
- **Custom theme builder** — A new "Custom" tile in the theme picker opens an inline panel with six colour pickers (accent, background, board surface, and three text levels). Colours apply live to the dashboard; the accent drives the derived glow, border and toggle tints via `color-mix`. A non-blocking WCAG contrast indicator flags hard-to-read text/background combinations without preventing the choice. The custom theme persists across reloads and can be combined with a custom background image. A reset button returns the pickers to neutral defaults. New DE/EN i18n strings; the `<input type="color">` pickers are labelled for accessibility.
- **Custom background via https URL** — The background URL field now accepts `https://` images in addition to local uploads (http stays out to avoid mixed content). A privacy note explains that a URL-loaded image is fetched from the remote server on every new tab.
### Changed
- Uploaded background images are downscaled (to the longest screen edge, capped at 2560px) and re-encoded as WebP before storage, to protect the `chrome.storage.local` quota.
- The extension-page CSP gains `img-src 'self' https: data: blob:` so https and data-URL backgrounds load deterministically instead of relying on the browser default.
- Onboarding slide 3 wording no longer hard-codes a fixed theme count.
---
## [2.3.0] — 2026-06-14
### Added
- **Command Palette (Ctrl+K)** — Overlay that live-filters bookmarks (title and URL) and board names from the keyboard. Arrow-key navigation, Enter opens the match, Escape closes. Read-only navigation, separate from the web search bar. Combobox/listbox ARIA pattern with focus trap and focus return. New DE/EN i18n strings.
- **Trash** — Deleted bookmarks and boards move to a 30-day trash instead of vanishing. Restore or permanently remove them from a new Settings section; entries older than 30 days are cleaned up automatically. Stored under its own storage key with a hard size cap so it cannot exhaust the storage quota.
- **Quick Save** — A global keyboard shortcut (default Alt+Shift+S, configurable in the browser shortcut settings) saves the current tab into a fixed Inbox board from any page. Backed by a background worker (service worker on Chrome/Opera, event page on Firefox) that appends to a dedicated pending queue, which the dashboard drains into the Inbox — separate write domains, so a save can never clobber the boards. A badge confirms the save, and open dashboard tabs sync the new bookmark live via a storage-change listener.
- **Free layout (bonus)** — Boards can be dragged to free positions via a drag handle, persisted per board. Positions are clamped back into view when the window shrinks, and the layout falls back to a stacked column on small screens. Each board can be pinned with a lock button: a locked board cannot be moved (its drag handle is hidden), preventing accidental repositioning. A drag only counts past a small movement threshold, so a mere click on the handle never shifts a board.
### Changed
- The bookmark- and board-delete paths no longer remove entries immediately; deletions now route through the trash.
- Chrome and Firefox manifests gain a background worker, an `action` entry and the `activeTab` / `commands` permissions to support Quick Save. Opera keeps its existing `tabs` permission and redirect worker.
---
## [2.2.0] — 2026-06-13
### Added
- **View Transitions** — Native cross-fade on theme switch and central modals (Settings, Theme-Picker, custom dialogs, bookmark import, add-board, add-bookmark, rename). Feature-detected via `document.startViewTransition`, instant swap on older browsers. Widgets, notebook sidebar and onboarding deliberately excluded.
- **`color-scheme: dark`** — Declares the dark UA scheme so native scrollbars and form controls match the dark themes.
- **Accessibility pass** — `role="dialog"` / `aria-modal` / `aria-labelledby` on Settings and Theme-Picker with new focus trap, Escape handling and focus return; `role="toolbar"` + per-button `aria-label` on the widget toolbar; keyboard-operable theme cards (`role="button"`, `tabindex`, Enter/Space); `role="switch"` + `aria-checked` on settings toggles; focusable boards and bookmarks; visible `:focus-visible` ring tinted in the theme accent. New ARIA strings run through the i18n pipeline. Verified with Lighthouse and the axe DevTools extension, not a formal WCAG 2.2 AA audit.
### Changed
- **`color-mix()` token refactor** — Accent-derived color tokens now computed via `color-mix()` from `var(--accent)`, classified per theme (formula vs. override). Theme-specific alpha values and real special colors stay overrides; no visible theme change. `--border-accent` `:root` drift (179,92,255 → 179,89,255) fixed at both the Nebula block and the `:root` default.
- **`@layer` cascade ordering** — CSS reorganized into six layers (base / theme / layout / components / theme-overrides / utilities) so theme component overrides win deterministically instead of relying on selector specificity.
- **`clamp()` fluid typography** — Clock, logo, board titles and main spacing scale fluidly via `clamp()`. Existing 768px / 480px breakpoints kept as a safety net.
### Accessibility
- **`prefers-reduced-motion`** — Unlayered `@media` block disables transitions and animations, including the `::view-transition-*` pseudo-elements. The 350ms widget teardown fallback timer is retained so widgets still close when `transitionend` no longer fires under reduced motion.
---
## [2.1.0] — 2026-04-16
### Added
+8 -22
View File
@@ -1,6 +1,6 @@
# ⬡ Hellion Dashboard v2.4.0
# ⬡ Hellion Dashboard v2.0.0
![Version](https://img.shields.io/badge/Version-2.4.0-blue)
![Version](https://img.shields.io/badge/Version-2.0.0-blue)
![JavaScript](https://img.shields.io/badge/JavaScript-Vanilla%20ES2020-F7DF1E?logo=javascript&logoColor=black)
![Manifest](https://img.shields.io/badge/Manifest-V3-green)
![License](https://img.shields.io/badge/License-CC%20BY--NC--SA%204.0-orange)
@@ -10,7 +10,7 @@
**No account. No subscription. No cloud. All data stays 100% local.**
A personal bookmark dashboard as a browser extension.
Boards, drag & drop, free layout, command palette, trash, quick save, 11 themes plus a custom theme builder, search bar, widget system with notes, calculator, timer and more.
Boards, drag & drop, 11 themes, search bar, widget system with notes, calculator, timer and more.
Full DE/EN language support with runtime switching. All in the browser, all offline.
No external data transmission, no trackers, no analytics, no ads.
@@ -38,10 +38,8 @@ What you see is what's saved. No magic.
### Boards & Bookmarks
- Boards as groups for links, sortable via drag & drop
- Free layout: drag boards to any position via a handle, each position is saved; a lock button pins a board in place
- Bookmarks with favicon, title and optional description
- Hide boards with the blur button (privacy mode)
- Trash: deleted bookmarks and boards are kept for 30 days before removal, with restore from Settings
- HTML import from browser bookmarks (Chrome, Edge, Firefox)
- JSON export & import (backup & restore)
@@ -50,16 +48,6 @@ What you see is what's saved. No magic.
- Google, DuckDuckGo or Bing, switchable with a click
- Toggleable via Settings
### Command Palette
- Open with **Ctrl+K**, live-filters all bookmarks (title and URL) and board names from the keyboard
- Arrow keys to navigate, Enter opens the match, Escape closes (read-only, separate from the web search bar)
### Quick Save
- Global shortcut (default **Alt+Shift+S**) saves the current tab into a fixed Inbox board from any page, without opening the dashboard
- A badge confirms the save; an open dashboard tab shows the new bookmark live
### Widget System
- **Notes & Checklists** — Floating note widgets with text or checklist template (max. 5)
@@ -70,7 +58,7 @@ What you see is what's saved. No magic.
- **Widget Toolbar** — Floating buttons for quick access, position (left/right) configurable in Settings
- All widgets: draggable, resizable, z-index stacking on click
### Themes
### 11 Themes
| Theme | Accent | Style |
|---|---|---|
@@ -86,8 +74,6 @@ What you see is what's saved. No magic.
| Avorion | `#2ec4a0` Turquoise | Deep Void |
| Hellion Stealth | `#5ec2ff` Tech Blue | Tactical Recon |
Plus a **custom theme**: build your own via the theme picker with six colour pickers (accent, background, board surface and three text levels). Colours apply live, the accent drives the derived glow, border and toggle tints, and a non-blocking WCAG contrast hint flags hard-to-read combinations without blocking the choice. Combinable with your own background image (local upload or https URL).
### Image Credits
| Theme | Source | License |
@@ -118,7 +104,7 @@ Plus a **custom theme**: build your own via the theme picker with six colour pic
### Appearance & Settings
- **Appearance modal** (header button), theme picker with custom theme builder, background image (local upload or https URL) and all display options in one modal
- **Appearance modal** (header button), theme picker, background image and all display options in one modal
- **Settings panel** (header button), widgets, data & help, danger zone
- **About footer**, developer info, license and support links permanently visible
- Compact mode, shorten titles, search bar toggle, open links in new tab, descriptions, hide extra bookmarks
@@ -302,7 +288,7 @@ hellion-newtab/
- **Responsive** — Tablet (768px) and smartphone (480px) breakpoints
- **Secure** — `createElement` instead of `innerHTML`, URL validation, storage error handling
- **Event Delegation** — One listener per board list instead of per bookmark (performance)
- **Theme System** — CSS Custom Properties, 11 themes plus a custom theme builder, custom background support (local upload or https URL)
- **Theme System** — CSS Custom Properties, 11 themes, custom background support
---
@@ -331,8 +317,8 @@ hellion-newtab/
```bash
# Create a release:
git tag v2.4.0
git push origin v2.4.0
git tag v2.0.0
git push origin v2.0.0
# → GitHub Action automatically creates release with ZIP files
```
+1 -5
View File
@@ -1,8 +1,4 @@
{
"extName": { "message": "Hellion NewTab" },
"extDesc": { "message": "Persönliches Bookmark-Dashboard mit Boards, Widgets und 11 Themes. Komplett lokal, keine Cloud, kein Tracking." },
"cmdQuickSave": { "message": "Aktuellen Tab in die Inbox speichern" },
"quickSaveBadge": { "message": "OK" },
"quickSaveSaved": { "message": "Gespeichert" },
"quickSaveNoTab": { "message": "Kein Tab" }
"extDesc": { "message": "Persönliches Bookmark-Dashboard mit Boards, Widgets und 11 Themes. Komplett lokal, keine Cloud, kein Tracking." }
}
+1 -5
View File
@@ -1,8 +1,4 @@
{
"extName": { "message": "Hellion NewTab" },
"extDesc": { "message": "Personal bookmark dashboard with boards, widgets, and 11 themes. Local-only, no cloud, no tracking." },
"cmdQuickSave": { "message": "Save current tab to Inbox" },
"quickSaveBadge": { "message": "OK" },
"quickSaveSaved": { "message": "Saved" },
"quickSaveNoTab": { "message": "No tab" }
"extDesc": { "message": "Personal bookmark dashboard with boards, widgets, and 11 themes. Local-only, no cloud, no tracking." }
}
+3 -22
View File
@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "__MSG_extName__",
"default_locale": "en",
"version": "2.4.0",
"version": "2.1.0",
"description": "__MSG_extDesc__",
"author": "Hellion Online Media - Florian Wathling",
"homepage_url": "https://hellion-media.de",
@@ -11,28 +11,9 @@
"newtab": "newtab.html"
},
"background": {
"scripts": ["src/js/quicksave-core.js", "src/js/background.js"]
},
"action": {
"default_title": "Hellion Dashboard"
},
"commands": {
"quick-save": {
"suggested_key": {
"default": "Alt+Shift+S",
"mac": "Alt+Shift+S"
},
"description": "__MSG_cmdQuickSave__"
}
},
"permissions": [
"storage",
"bookmarks",
"activeTab"
"bookmarks"
],
"browser_specific_settings": {
@@ -54,7 +35,7 @@
}
],
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'; img-src 'self' https: data: blob:"
"extension_pages": "script-src 'self'; object-src 'self'"
},
"icons": {
"16": "assets/icons/icon16.png",
+3 -19
View File
@@ -2,32 +2,16 @@
"manifest_version": 3,
"name": "__MSG_extName__",
"default_locale": "en",
"version": "2.4.0",
"version": "2.1.0",
"description": "__MSG_extDesc__",
"author": "Hellion Online Media - Florian Wathling",
"homepage_url": "https://hellion-media.de",
"chrome_url_overrides": {
"newtab": "newtab.html"
},
"background": {
"service_worker": "src/js/background.js"
},
"action": {
"default_title": "Hellion Dashboard"
},
"commands": {
"quick-save": {
"suggested_key": {
"default": "Alt+Shift+S",
"mac": "Alt+Shift+S"
},
"description": "__MSG_cmdQuickSave__"
}
},
"permissions": [
"storage",
"bookmarks",
"activeTab"
"bookmarks"
],
"web_accessible_resources": [
{
@@ -36,7 +20,7 @@
}
],
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'; img-src 'self' https: data: blob:"
"extension_pages": "script-src 'self'; object-src 'self'"
},
"icons": {
"16": "assets/icons/icon16.png",
+2 -12
View File
@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "__MSG_extName__",
"default_locale": "en",
"version": "2.4.0",
"version": "2.1.0",
"description": "__MSG_extDesc__",
"author": "Hellion Online Media - Florian Wathling",
"homepage_url": "https://hellion-media.de",
@@ -40,18 +40,8 @@
"default_title": "Hellion Dashboard"
},
"commands": {
"quick-save": {
"suggested_key": {
"default": "Alt+Shift+S",
"mac": "Alt+Shift+S"
},
"description": "__MSG_cmdQuickSave__"
}
},
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'; img-src 'self' https: data: blob:"
"extension_pages": "script-src 'self'; object-src 'self'"
},
"icons": {
"16": "assets/icons/icon16.png",
+25 -63
View File
@@ -39,10 +39,6 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a10 10 0 100 20 4 4 0 01-1-7.9 1 1 0 011-.1h1a2 2 0 002-2V7a5 5 0 00-3-4.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="6" r="1.5"/><circle cx="17" cy="10" r="1.5"/><circle cx="9" cy="17" r="1.5"/></svg>
<span data-i18n="header.theme">Darstellung</span>
</button>
<button class="btn-icon" id="btnPalette" title="Lesezeichen durchsuchen (Strg+K)" data-i18n-title="palette.discover">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<span data-i18n="palette.discover_label">Suchen</span>
</button>
<button class="btn-icon" id="btnSettings" title="Einstellungen" data-i18n-title="header.settings_title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
<span data-i18n="header.settings">Settings</span>
@@ -64,7 +60,7 @@
</div>
<!-- WIDGET TOOLBAR -->
<div class="widget-toolbar" id="widgetToolbar" role="toolbar" aria-orientation="vertical" data-i18n-aria-label="toolbar.label">
<div class="widget-toolbar" id="widgetToolbar">
<button class="widget-toolbar-btn" data-action="new-note" data-i18n-title="toolbar.note" title="Note erstellen">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
</button>
@@ -107,9 +103,9 @@
<!-- SETTINGS PANEL -->
<div class="panel-overlay" id="settingsOverlay"></div>
<aside class="settings-panel" id="settingsPanel" role="dialog" aria-modal="true" aria-labelledby="settingsPanelTitle" aria-hidden="true">
<aside class="settings-panel" id="settingsPanel">
<div class="panel-header">
<span id="settingsPanelTitle" data-i18n="settings.title">Einstellungen</span>
<span data-i18n="settings.title">Einstellungen</span>
<button class="btn-close" id="btnCloseSettings" data-i18n-title="dialog.close"></button>
</div>
<div class="panel-body">
@@ -158,7 +154,7 @@
<span class="setting-desc" data-i18n="settings.image_ref.desc">Bilder als Referenz anzeigen (nur aktuelle Session)</span>
</div>
<label class="toggle">
<input type="checkbox" id="settingImageRef" role="switch" aria-checked="false">
<input type="checkbox" id="settingImageRef">
<span class="slider"></span>
</label>
</div>
@@ -204,20 +200,6 @@
</div>
</section>
<!-- PAPIERKORB / TRASH -->
<section class="settings-section" data-section="trash">
<button class="settings-section-title" type="button">
<span class="section-chevron"></span>
<span data-i18n="trash.section">PAPIERKORB</span>
</button>
<div class="section-content">
<div class="trash-list" id="trashList"></div>
<div class="setting-row trash-actions" id="trashActionsRow">
<button class="btn-danger" id="btnEmptyTrash" data-i18n="trash.empty_btn">Papierkorb leeren</button>
</div>
</div>
</section>
<!-- DANGER ZONE -->
<section class="settings-section" data-section="danger">
<button class="settings-section-title danger" type="button">
@@ -241,7 +223,7 @@
<div class="panel-footer">
<div class="about-block">
<div class="about-logo" data-i18n="about.title">⬡ HELLION NEWTAB</div>
<div class="about-version">Version 2.4.0 · by Hellion Online Media</div>
<div class="about-version">Version 2.1.0 · by Hellion Online Media</div>
<div class="about-links">
<a href="https://hellion-media.de/impressum" target="_blank" class="about-link">
@@ -309,84 +291,67 @@
<!-- THEME PICKER MODAL -->
<div class="modal-overlay" id="themeOverlay">
<div class="theme-modal" id="themeModal" role="dialog" aria-modal="true" aria-labelledby="themeModalTitle" aria-hidden="true">
<div class="theme-modal" id="themeModal">
<div class="modal-header">
<span id="themeModalTitle" data-i18n="modal.theme_header">Darstellung</span>
<span data-i18n="modal.theme_header">Darstellung</span>
<button class="btn-close" id="btnCloseTheme" data-i18n-title="dialog.close"></button>
</div>
<div class="theme-grid">
<div class="theme-card active" data-value="nebula" role="button" tabindex="0" aria-pressed="true" data-i18n-aria-label="theme.card.nebula">
<div class="theme-card active" data-value="nebula">
<img class="theme-card-img" src="assets/themes/bg-nebula.webp" alt="Nebula" />
<span class="theme-card-label">Nebula</span>
<span class="theme-card-check"></span>
</div>
<div class="theme-card" data-value="crescent" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.crescent">
<div class="theme-card" data-value="crescent">
<img class="theme-card-img" src="assets/themes/bg-crescent.webp" alt="Crescent" />
<span class="theme-card-label">Crescent</span>
<span class="theme-card-check"></span>
</div>
<div class="theme-card" data-value="event-horizon" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.event_horizon">
<div class="theme-card" data-value="event-horizon">
<img class="theme-card-img" src="assets/themes/bg-event-horizon.webp" alt="Event Horizon" />
<span class="theme-card-label">Event Horizon</span>
<span class="theme-card-check"></span>
</div>
<div class="theme-card" data-value="merchantman" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.merchantman">
<div class="theme-card" data-value="merchantman">
<img class="theme-card-img" src="assets/themes/bg-merchantman.webp" alt="Merchantman" />
<span class="theme-card-label">Merchantman</span>
<span class="theme-card-check"></span>
</div>
<div class="theme-card" data-value="julia-jin" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.julia_jin">
<div class="theme-card" data-value="julia-jin">
<img class="theme-card-img" src="assets/themes/bg-julia-jin.webp" alt="Julia &amp; Jin" />
<span class="theme-card-label">Julia &amp; Jin</span>
<span class="theme-card-check"></span>
</div>
<div class="theme-card" data-value="sc-sunset" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.sc_sunset">
<div class="theme-card" data-value="sc-sunset">
<img class="theme-card-img" src="assets/themes/bg-sc-sunset.webp" alt="SC Sunset" />
<span class="theme-card-label">SC Sunset</span>
<span class="theme-card-check"></span>
</div>
<div class="theme-card" data-value="hellion-hud" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.hellion_hud">
<div class="theme-card" data-value="hellion-hud">
<img class="theme-card-img" src="assets/themes/bg-hellion-hud.webp" alt="Hellion HUD" />
<span class="theme-card-label">HUD</span>
<span class="theme-card-check"></span>
</div>
<div class="theme-card" data-value="hellion-energy" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.hellion_energy">
<div class="theme-card" data-value="hellion-energy">
<img class="theme-card-img" src="assets/themes/bg-hellion-energy.webp" alt="Hellion Energy" />
<span class="theme-card-label">Energy</span>
<span class="theme-card-check"></span>
</div>
<div class="theme-card" data-value="satisfactory" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.satisfactory">
<div class="theme-card" data-value="satisfactory">
<img class="theme-card-img" src="assets/themes/bg-satisfactory.webp" alt="Satisfactory" />
<span class="theme-card-label">Satisfactory</span>
<span class="theme-card-check"></span>
</div>
<div class="theme-card" data-value="avorion" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.avorion">
<div class="theme-card" data-value="avorion">
<img class="theme-card-img" src="assets/themes/bg-avorion.webp" alt="Avorion" />
<span class="theme-card-label">Avorion</span>
<span class="theme-card-check"></span>
</div>
<div class="theme-card" data-value="hellion-stealth" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.hellion_stealth">
<div class="theme-card" data-value="hellion-stealth">
<img class="theme-card-img" src="assets/themes/bg-scPolaris.webp" alt="Hellion Stealth" />
<span class="theme-card-label">Stealth</span>
<span class="theme-card-check"></span>
</div>
<div class="theme-card theme-card-custom" data-value="custom" role="button" tabindex="0" aria-pressed="false" data-i18n-aria-label="theme.card.custom">
<span class="theme-card-custom-swatch"></span>
<span class="theme-card-label" data-i18n="theme.builder.title">Eigenes</span>
<span class="theme-card-check"></span>
</div>
</div>
<div class="theme-builder-panel hidden" id="themeBuilderPanel">
<div class="tb-grid">
<div class="tb-picker"><input type="color" id="tbAccent" value="#6c8cff"><label for="tbAccent" data-i18n="theme.builder.accent">Akzent</label></div>
<div class="tb-picker"><input type="color" id="tbBg" value="#0b0d12"><label for="tbBg" data-i18n="theme.builder.bg">Hintergrund</label></div>
<div class="tb-picker"><input type="color" id="tbBoard" value="#141821"><label for="tbBoard" data-i18n="theme.builder.board">Board-Fläche</label></div>
<div class="tb-picker"><input type="color" id="tbText" value="#e6e8ef"><label for="tbText" data-i18n="theme.builder.text">Text primär</label></div>
<div class="tb-picker"><input type="color" id="tbTextSec" value="#9aa3b8"><label for="tbTextSec" data-i18n="theme.builder.text_secondary">Text sekundär</label></div>
<div class="tb-picker"><input type="color" id="tbTextMuted" value="#5b6478"><label for="tbTextMuted" data-i18n="theme.builder.text_muted">Text gedämpft</label></div>
</div>
<div class="tb-contrast good" id="tbContrast"><span class="tb-dot"></span><span id="tbContrastText" data-i18n="theme.builder.contrast_good">Gut lesbar</span></div>
<div class="tb-foot"><button class="tb-reset" id="tbReset" data-i18n="theme.builder.reset">Zurücksetzen</button></div>
</div>
<div class="theme-modal-section">
<h3 class="settings-section-title" data-i18n="settings.section.bg">HINTERGRUND</h3>
@@ -398,10 +363,9 @@
<button class="btn-small" id="btnChangeBg" data-i18n="settings.bg_change">Ändern</button>
</div>
<div class="setting-row hidden" id="bgInputRow">
<input type="text" class="text-input full-width" id="bgUrlInput" data-i18n-placeholder="settings.bg_url.placeholder" placeholder="https:// oder leer für Standard" />
<input type="text" class="text-input full-width" id="bgUrlInput" placeholder="https://... oder leer für Standard" />
<button class="btn-small" id="btnApplyBg" data-i18n="settings.bg_apply">Übernehmen</button>
</div>
<p class="setting-desc bg-url-hint hidden" id="bgUrlHint" data-i18n="settings.bg_url.privacy_hint">Hinweis: Ein per URL eingebundenes Bild wird bei jedem Öffnen vom fremden Server geladen.</p>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label" data-i18n="settings.bg_upload">Datei hochladen</span>
@@ -418,42 +382,42 @@
<span class="setting-label" data-i18n="settings.compact">Kompaktmodus</span>
<span class="setting-desc" data-i18n="settings.compact.desc">Weniger Abstand für mehr Bookmarks</span>
</div>
<label class="toggle"><input type="checkbox" id="settingCompact" role="switch" aria-checked="false" /><span class="slider"></span></label>
<label class="toggle"><input type="checkbox" id="settingCompact" /><span class="slider"></span></label>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label" data-i18n="settings.shorten">Lange Titel kürzen</span>
<span class="setting-desc" data-i18n="settings.shorten.desc">Titel auf eine Zeile mit „…" kürzen</span>
</div>
<label class="toggle"><input type="checkbox" id="settingShorten" role="switch" aria-checked="false" /><span class="slider"></span></label>
<label class="toggle"><input type="checkbox" id="settingShorten" /><span class="slider"></span></label>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label" data-i18n="settings.search">Suchleiste anzeigen</span>
<span class="setting-desc" data-i18n="settings.search.desc">Suchleiste unter dem Header ein/aus</span>
</div>
<label class="toggle"><input type="checkbox" id="settingShowSearch" role="switch" aria-checked="true" checked /><span class="slider"></span></label>
<label class="toggle"><input type="checkbox" id="settingShowSearch" checked /><span class="slider"></span></label>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label" data-i18n="settings.newtab">Links in neuem Tab</span>
<span class="setting-desc" data-i18n="settings.newtab.desc">Bookmarks in neuem Browser-Tab öffnen</span>
</div>
<label class="toggle"><input type="checkbox" id="settingNewTab" role="switch" aria-checked="true" checked /><span class="slider"></span></label>
<label class="toggle"><input type="checkbox" id="settingNewTab" checked /><span class="slider"></span></label>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label" data-i18n="settings.showdesc">Beschreibungen anzeigen</span>
<span class="setting-desc" data-i18n="settings.showdesc.desc">Gespeicherte Beschreibung unter Bookmarks</span>
</div>
<label class="toggle"><input type="checkbox" id="settingShowDesc" role="switch" aria-checked="false" /><span class="slider"></span></label>
<label class="toggle"><input type="checkbox" id="settingShowDesc" /><span class="slider"></span></label>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label" data-i18n="settings.hideextra">Bookmarks ausblenden</span>
<span class="setting-desc" data-i18n="settings.hideextra.desc">Überzählige Bookmarks in langen Boards verstecken</span>
</div>
<label class="toggle"><input type="checkbox" id="settingHideExtra" role="switch" aria-checked="false" /><span class="slider"></span></label>
<label class="toggle"><input type="checkbox" id="settingHideExtra" /><span class="slider"></span></label>
</div>
<div class="setting-row" id="visibleCountRow">
<div class="setting-info">
@@ -528,7 +492,6 @@
<!-- Storage muss zuerst -->
<script src="src/js/storage.js"></script>
<!-- State & Hilfsfunktionen -->
<script src="src/js/quicksave-core.js"></script>
<script src="src/js/state.js"></script>
<!-- i18n (nach state.js, vor allen Modulen die t() nutzen könnten) -->
<script src="src/js/i18n.js"></script>
@@ -541,7 +504,6 @@
<script src="src/js/boards.js"></script>
<script src="src/js/settings.js"></script>
<script src="src/js/search.js"></script>
<script src="src/js/palette.js"></script>
<script src="src/js/widgets.js"></script>
<script src="src/js/notes.js"></script>
<script src="src/js/calculator.js"></script>
+2 -1
View File
@@ -44,7 +44,8 @@
],
"vulnerabilityAlerts": {
"labels": ["security", "vulnerability"],
"schedule": ["at any time"]
"schedule": ["at any time"],
"prPriority": 10
},
"lockFileMaintenance": {
"enabled": true,
+137 -375
View File
@@ -1,12 +1,3 @@
/* =============================================
CASCADE LAYERS (v2.2) — Reihenfolge = Prioritaet (spaeter gewinnt)
theme-overrides MUSS nach components stehen, sonst verlieren die
theme-spezifischen Komponenten-Regeln (Board-Blur, Cinzel-Logos,
Hover-Tints) und alle 11 Themes brechen.
prefers-reduced-motion bleibt bewusst UNGESCHICHTET (Phase 4).
============================================= */
@layer base, theme, layout, components, theme-overrides, utilities;
/* =============================================
HELLION Dashboard — Theme System
Themes: nebula | crescent | event-horizon | merchantman | julia-jin | sc-sunset | hellion-hud | hellion-energy
@@ -18,7 +9,6 @@
- Inter: Fließtext und allgemeine Lesbarkeit
- Cinzel: Alternative Display-Schriftart für bestimmte Themes
============================================= */
@layer base {
/* Rajdhani - Lokal */
@font-face {
font-family: 'Rajdhani';
@@ -56,15 +46,9 @@
/* ---- BASE VARIABLES (Nebula = Default) ---- */
:root {
--accent: #b359ff;
/* Akzent-Töne als Formel aus --accent abgeleitet (Spec Block 1, color-mix Mittelweg).
--*-pct ist die Pro-Theme-Alpha-Variable; Default = häufigster Wert über alle 11 Themes.
Themes mit abweichendem Alpha überschreiben nur die Prozent-Variable im theme-Layer. */
--accent-dim-pct: 12%;
--accent-glow-pct: 8%;
--border-accent-pct: 25%;
--accent-dim: color-mix(in srgb, var(--accent) var(--accent-dim-pct), transparent);
--accent-glow: color-mix(in srgb, var(--accent) var(--accent-glow-pct), transparent);
--border-accent: color-mix(in srgb, var(--accent) var(--border-accent-pct), transparent);
--accent-dim: rgba(179, 89, 255, 0.12);
--accent-glow: rgba(179, 89, 255, 0.05);
--border-accent: rgba(179, 92, 255, 0.25);
--bg-primary: #050308;
--bg-board: rgba(10, 6, 14, 0.55);
--border: rgba(255, 255, 255, 0.06);
@@ -77,18 +61,14 @@
--radius: 8px;
--radius-sm: 5px;
--board-width: 240px;
--spacing: clamp(0.5rem, 0.4583rem + 0.14vw, 0.625rem);
--spacing: 10px;
--spacing-compact: 5px;
--overlay-bg: radial-gradient(circle at center, rgba(10,6,14,0.3) 0%, rgba(5,3,8,0.85) 100%);
--header-bg: rgba(10,6,14,0.90);
--board-hover-border-pct: 22%;
--logo-shadow-pct: 45%;
--toggle-on-bg-pct: 20%;
--board-hover-border: color-mix(in srgb, var(--accent) var(--board-hover-border-pct), transparent);
--board-hover-border: rgba(179,89,255,0.18);
--toggle-on-bg: rgba(214,92,255,0.22);
--logo-shadow: color-mix(in srgb, var(--accent) var(--logo-shadow-pct), transparent);
--logo-shadow: rgba(179,89,255,0.35);
--bg-solid-fallback: #0a060e;
color-scheme: dark;
}
/* Fallback fuer Browser die backdrop-filter blockieren (z.B. Brave Shields) */
@@ -102,17 +82,15 @@
background-color: var(--bg-solid-fallback, var(--bg-primary));
}
}
}
@layer theme {
/* ============================================
THEME: NEBULA (magenta / cosmic nebula)
============================================ */
[data-theme="nebula"] {
--accent: #b359ff;
--accent-glow-pct: 5%;
--board-hover-border-pct: 18%;
--logo-shadow-pct: 35%;
--accent-dim: rgba(179, 89, 255, 0.12);
--accent-glow: rgba(179, 89, 255, 0.05);
--border-accent: rgba(179, 92, 255, 0.25);
--bg-primary: #050308;
--bg-board: rgba(10, 6, 14, 0.55);
--border: rgba(255, 255, 255, 0.055);
@@ -123,18 +101,22 @@
--font-body: 'Inter', sans-serif;
--overlay-bg: radial-gradient(circle at center, rgba(10,6,14,0.3) 0%, rgba(5,3,8,0.85) 100%);
--header-bg: rgba(10,6,14,0.92);
--board-hover-border: rgba(179,89,255,0.18);
--toggle-on-bg: rgba(214,92,255,0.22);
--logo-shadow: rgba(179,89,255,0.35);
--bg-solid-fallback: #0a060e;
}
[data-theme="nebula"] .board { border-color: rgba(214,92,255,0.10); }
[data-theme="nebula"] .bm-item:hover { background: rgba(214,92,255,0.05); }
/* ============================================
THEME: CRESCENT (gold / minimalist night)
============================================ */
[data-theme="crescent"] {
--accent: #d4bd8a;
--accent-glow-pct: 5%;
--board-hover-border-pct: 20%;
--logo-shadow-pct: 40%;
--accent-dim: rgba(212, 189, 138, 0.12);
--accent-glow: rgba(212, 189, 138, 0.05);
--border-accent: rgba(212, 189, 138, 0.25);
--bg-primary: #06080f;
--bg-board: rgba(8, 12, 22, 0.45);
--border: rgba(212, 189, 138, 0.10);
@@ -145,17 +127,27 @@
--font-body: 'Inter', sans-serif;
--overlay-bg: radial-gradient(circle at center, rgba(6,8,15,0.2) 0%, rgba(6,8,15,0.9) 100%);
--header-bg: rgba(6,8,15,0.95);
--board-hover-border: rgba(212, 189, 138, 0.20);
--toggle-on-bg: rgba(200,168,74,0.22);
--logo-shadow: rgba(212, 189, 138, 0.40);
--bg-solid-fallback: #080c16;
letter-spacing: 0.5px;
}
[data-theme="crescent"] .logo { font-family: 'Cinzel', serif; letter-spacing: 4px; }
[data-theme="crescent"] .clock { font-family: 'Cinzel', serif; }
[data-theme="crescent"] .board-title { letter-spacing: 2px; }
[data-theme="crescent"] .bm-item:hover { background: rgba(200,168,74,0.05); }
[data-theme="crescent"] .board { border-color: rgba(200,168,74,0.10); }
/* ============================================
THEME: EVENT HORIZON (Cosmic Purple / deep space)
============================================ */
[data-theme="event-horizon"] {
--accent: #9d5cff;
--border-accent-pct: 28%;
--accent-dim: rgba(157, 92, 255, 0.12);
--accent-glow: rgba(157, 92, 255, 0.08);
--border-accent: rgba(157, 92, 255, 0.28);
--bg-primary: #040308;
--bg-board: rgba(8, 5, 15, 0.45);
--border: rgba(157, 92, 255, 0.12);
@@ -166,17 +158,23 @@
--font-body: 'Inter', sans-serif;
--overlay-bg: radial-gradient(circle at center, rgba(4,3,8,0.2) 0%, rgba(4,3,8,0.95) 100%);
--header-bg: rgba(4,3,8,0.96);
--board-hover-border: rgba(157, 92, 255, 0.22);
--toggle-on-bg: rgba(224,128,48,0.22);
--logo-shadow: rgba(157, 92, 255, 0.45);
--bg-solid-fallback: #08050f;
}
[data-theme="event-horizon"] .board { border-color: rgba(157, 92, 255, 0.15); }
[data-theme="event-horizon"] .bm-item:hover { background: rgba(157, 92, 255, 0.08); }
/* ============================================
THEME: MERCHANTMAN (Emerald / industrial sci-fi)
============================================ */
[data-theme="merchantman"] {
--accent: #2eb8b8;
--accent-glow-pct: 6%;
--board-hover-border-pct: 20%;
--accent-dim: rgba(46, 184, 184, 0.12);
--accent-glow: rgba(46, 184, 184, 0.06);
--border-accent: rgba(46, 184, 184, 0.25);
--bg-primary: #040808;
--bg-board: rgba(6, 10, 10, 0.58);
--border: rgba(46, 184, 184, 0.10);
@@ -187,17 +185,22 @@
--font-body: 'Inter', sans-serif;
--overlay-bg: radial-gradient(circle at 30% 40%, rgba(4,8,8,0.2) 0%, rgba(4,8,8,0.92) 100%);
--header-bg: rgba(4,8,8,0.95);
--board-hover-border: rgba(46, 184, 184, 0.20);
--toggle-on-bg: rgba(78,207,207,0.22);
--logo-shadow: rgba(46, 184, 184, 0.45);
--bg-solid-fallback: #060a0a;
}
[data-theme="merchantman"] .board { border-color: rgba(46, 184, 184, 0.12); }
[data-theme="merchantman"] .bm-item:hover { background: rgba(46, 184, 184, 0.06); }
/* ============================================
THEME: JULIA & JIN (Aetherial Night / FFXIV)
============================================ */
[data-theme="julia-jin"] {
--accent: #7db3ff;
--border-accent-pct: 30%;
--logo-shadow-pct: 50%;
--accent-dim: rgba(125, 179, 255, 0.12);
--accent-glow: rgba(125, 179, 255, 0.08);
--border-accent: rgba(125, 179, 255, 0.30);
--bg-primary: #03050a;
--bg-board: rgba(7, 10, 20, 0.60);
--border: rgba(125, 179, 255, 0.12);
@@ -208,16 +211,25 @@
--font-body: 'Inter', sans-serif;
--overlay-bg: linear-gradient(180deg, rgba(3,5,10,0.85) 0%, rgba(3,5,10,0.25) 50%, rgba(3,5,10,0.92) 100%);
--header-bg: rgba(3,5,10,0.94);
--board-hover-border: rgba(125, 179, 255, 0.22);
--toggle-on-bg: rgba(91,159,255,0.22);
--logo-shadow: rgba(125, 179, 255, 0.50);
--bg-solid-fallback: #070a14;
}
[data-theme="julia-jin"] .logo { font-family: 'Cinzel', serif; letter-spacing: 5px; text-transform: uppercase; }
[data-theme="julia-jin"] .clock { font-family: 'Cinzel', serif; font-weight: 600; }
[data-theme="julia-jin"] .board-title { letter-spacing: 2px; font-weight: 500; }
[data-theme="julia-jin"] .board { border-color: rgba(125, 179, 255, 0.15); backdrop-filter: blur(4px); }
[data-theme="julia-jin"] .bm-item:hover { background: rgba(125, 179, 255, 0.08); }
/* ============================================
THEME: SC Sunset - Horizon Glow
============================================ */
[data-theme="sc-sunset"] {
--accent: #ff8c3d;
--border-accent-pct: 28%;
--accent-dim: rgba(255, 140, 61, 0.12);
--accent-glow: rgba(255, 140, 61, 0.08);
--border-accent: rgba(255, 140, 61, 0.28);
--bg-primary: #080503;
--bg-board: rgba(15, 10, 8, 0.55);
--border: rgba(255, 140, 61, 0.10);
@@ -228,19 +240,25 @@
--font-body: 'Inter', sans-serif;
--overlay-bg: radial-gradient(circle at 50% 60%, rgba(8,5,3,0.1) 0%, rgba(8,5,3,0.92) 100%);
--header-bg: rgba(8,5,3,0.94);
--board-hover-border: rgba(255, 140, 61, 0.22);
--toggle-on-bg: rgba(240,124,48,0.22);
--logo-shadow: rgba(255, 140, 61, 0.45);
--bg-solid-fallback: #0f0a08;
}
[data-theme="sc-sunset"] .board {
border-color: rgba(255, 140, 61, 0.15);
backdrop-filter: blur(6px);}
[data-theme="sc-sunset"] .bm-item:hover {
background: rgba(255, 140, 61, 0.08); }
/* ============================================
THEME: HELLION HUD (circuit board / red+green)
============================================ */
[data-theme="hellion-hud"] {
--accent: #32ff6a;
--accent-dim-pct: 10%;
--accent-glow-pct: 5%;
--board-hover-border-pct: 20%;
--logo-shadow-pct: 40%;
--accent-dim: rgba(50, 255, 106, 0.10);
--accent-glow: rgba(50, 255, 106, 0.05);
--border-accent: rgba(50, 255, 106, 0.25);
--bg-primary: #030503;
--bg-board: rgba(5, 8, 5, 0.65);
--border: rgba(50, 255, 106, 0.12);
@@ -251,19 +269,31 @@
--font-body: 'Inter', sans-serif;
--overlay-bg: radial-gradient(circle at center, rgba(3,5,3,0.15) 0%, rgba(3,5,3,0.95) 100%);
--header-bg: rgba(3,5,3,0.96);
--board-hover-border: rgba(50, 255, 106, 0.20);
--toggle-on-bg: rgba(34,204,68,0.20);
--logo-shadow: rgba(50, 255, 106, 0.40);
--bg-solid-fallback: #050805;
--danger: #ff4d4d;
}
[data-theme="hellion-hud"] .board {
border-color: rgba(50, 255, 106, 0.15);
backdrop-filter: blur(8px) brightness(1.1);
}
[data-theme="hellion-hud"] .clock {
font-family: 'Rajdhani', sans-serif;
font-weight: 700;
text-shadow: 0 0 10px var(--accent-glow);
}
/* ============================================
THEME: HELLION ENERGY (matrix / tactical green)
============================================ */
[data-theme="hellion-energy"] {
--accent: #1eff8e;
--border-accent-pct: 30%;
--board-hover-border-pct: 25%;
--logo-shadow-pct: 60%;
--accent-dim: rgba(30, 255, 142, 0.12);
--accent-glow: rgba(30, 255, 142, 0.08);
--border-accent: rgba(30, 255, 142, 0.30);
--bg-primary: #020503;
--bg-board: rgba(4, 7, 5, 0.60);
--border: rgba(30, 255, 142, 0.12);
@@ -274,18 +304,29 @@
--font-body: 'Inter', sans-serif;
--overlay-bg: radial-gradient(circle at center, rgba(2,5,3,0.1) 0%, rgba(2,5,3,0.95) 100%);
--header-bg: rgba(2,5,3,0.96);
--board-hover-border: rgba(30, 255, 142, 0.25);
--toggle-on-bg: rgba(0,232,122,0.18);
--logo-shadow: rgba(30, 255, 142, 0.60);
--bg-solid-fallback: #040705;
}
[data-theme="hellion-energy"] .board {
border-color: rgba(30, 255, 142, 0.15);
backdrop-filter: blur(10px) saturate(1.2);
}
[data-theme="hellion-energy"] .bm-item:hover {
background: rgba(30, 255, 142, 0.10);
box-shadow: inset 0 0 10px rgba(30, 255, 142, 0.05);
}
/* ============================================
THEME: SATISFACTORY (Industrial Desert)
============================================ */
[data-theme="satisfactory"] {
--accent: #00b4d8;
--border-accent-pct: 35%;
--board-hover-border-pct: 25%;
--logo-shadow-pct: 40%;
--accent-dim: rgba(0, 180, 216, 0.12);
--accent-glow: rgba(0, 180, 216, 0.08);
--border-accent: rgba(0, 180, 216, 0.35);
--bg-primary: #1a0f08;
--bg-board: rgba(26, 15, 8, 0.65);
--border: rgba(0, 180, 216, 0.15);
@@ -299,17 +340,25 @@
rgba(26,15,8,0.15) 50%,
rgba(26,15,8,0.90) 100%);
--header-bg: rgba(26,15,8,0.95);
--toggle-on-bg: color-mix(in srgb, var(--accent) var(--toggle-on-bg-pct), transparent);
--board-hover-border: rgba(0, 180, 216, 0.25);
--toggle-on-bg: rgba(0, 180, 216, 0.20);
--logo-shadow: rgba(0, 180, 216, 0.40);
--bg-solid-fallback: #1a0f08;
}
[data-theme="satisfactory"] .logo { font-family: 'Rajdhani', sans-serif; font-weight: 700; letter-spacing: 3px; text-transform: uppercase; }
[data-theme="satisfactory"] .clock { font-family: 'Rajdhani', sans-serif; font-weight: 600; color: var(--accent); }
[data-theme="satisfactory"] .board-title { font-family: 'Rajdhani', sans-serif; letter-spacing: 1.5px; text-transform: uppercase; }
[data-theme="satisfactory"] .board { border-color: rgba(0, 180, 216, 0.20); backdrop-filter: blur(12px); }
[data-theme="satisfactory"] .bm-item:hover { background: rgba(0, 180, 216, 0.10); }
/* ============================================
THEME: AVORION (Deep Void)
============================================ */
[data-theme="avorion"] {
--accent: #2ec4a0;
--border-accent-pct: 30%;
--logo-shadow-pct: 50%;
--accent-dim: rgba(46, 196, 160, 0.12);
--accent-glow: rgba(46, 196, 160, 0.08);
--border-accent: rgba(46, 196, 160, 0.30);
--bg-primary: #020d0c;
--bg-board: rgba(2, 13, 12, 0.60);
--border: rgba(46, 196, 160, 0.12);
@@ -322,18 +371,25 @@
transparent 0%,
rgba(2, 13, 12, 0.95) 100%);
--header-bg: rgba(2, 13, 12, 0.94);
--toggle-on-bg-pct: 18%;
--toggle-on-bg: color-mix(in srgb, var(--accent) var(--toggle-on-bg-pct), transparent);
--board-hover-border: rgba(46, 196, 160, 0.22);
--toggle-on-bg: rgba(46, 196, 160, 0.18);
--logo-shadow: rgba(46, 196, 160, 0.50);
--bg-solid-fallback: #020d0c;
}
[data-theme="avorion"] .logo { font-family: 'Rajdhani', sans-serif; font-weight: 500; letter-spacing: 6px; text-transform: uppercase; }
[data-theme="avorion"] .clock { font-family: 'Rajdhani', sans-serif; font-weight: 400; color: var(--accent); }
[data-theme="avorion"] .board-title { font-family: 'Rajdhani', sans-serif; font-weight: 500; text-transform: uppercase; }
[data-theme="avorion"] .board { border-color: rgba(46, 196, 160, 0.15); backdrop-filter: blur(8px); }
[data-theme="avorion"] .bm-item:hover { background: rgba(46, 196, 160, 0.08); }
/* ============================================
THEME: HELLION STEALTH (Tactical Recon)
============================================ */
[data-theme="hellion-stealth"] {
--accent: #5ec2ff;
--border-accent-pct: 35%;
--board-hover-border-pct: 25%;
--accent-dim: rgba(94, 194, 255, 0.12);
--accent-glow: rgba(94, 194, 255, 0.08);
--border-accent: rgba(94, 194, 255, 0.35);
--bg-primary: #0d0f12;
--bg-board: rgba(13, 15, 18, 0.70);
--border: rgba(94, 194, 255, 0.15);
@@ -346,106 +402,21 @@
transparent 0%,
rgba(13, 15, 18, 0.90) 100%);
--header-bg: rgba(13, 15, 18, 0.96);
--toggle-on-bg: color-mix(in srgb, var(--accent) var(--toggle-on-bg-pct), transparent);
--board-hover-border: rgba(94, 194, 255, 0.25);
--toggle-on-bg: rgba(94, 194, 255, 0.20);
--logo-shadow: rgba(94, 194, 255, 0.45);
--bg-solid-fallback: #0d0f12;
}
/* ============================================
THEME: CUSTOM (User-Theme-Builder, neutrale Defaults)
Inline-Vars aus applyCustomTheme() ueberschreiben die 6 Picker-Werte.
============================================ */
[data-theme="custom"] {
--accent: #6c8cff;
--accent-glow-pct: 5%;
--board-hover-border-pct: 20%;
--logo-shadow-pct: 38%;
--toggle-on-bg-pct: 20%;
--bg-primary: #0b0d12;
--bg-board: rgba(20, 24, 33, 0.55);
--border: rgba(255, 255, 255, 0.06);
--text-primary: #e6e8ef;
--text-secondary: #9aa3b8;
--text-muted: #5b6478;
--font-display: 'Rajdhani', sans-serif;
--font-body: 'Inter', sans-serif;
--overlay-bg: radial-gradient(circle at center, color-mix(in srgb, var(--bg-primary) 35%, transparent) 0%, color-mix(in srgb, var(--bg-primary) 88%, transparent) 100%);
--header-bg: color-mix(in srgb, var(--bg-primary) 92%, transparent);
--toggle-on-bg: color-mix(in srgb, var(--accent) var(--toggle-on-bg-pct), transparent);
--bg-solid-fallback: var(--bg-primary);
}
}
/* ============================================
THEME-SCOPED KOMPONENTEN-REGELN (Sammelblock fuer @layer theme-overrides)
Aus den 11 [data-theme]-Bloecken extrahiert. Muss spaeter als components
greifen, sonst verlieren Board-Blur, Cinzel-Logos und Hover-Tints.
============================================ */
@layer theme-overrides {
[data-theme="nebula"] .board { border-color: rgba(214,92,255,0.10); }
[data-theme="nebula"] .bm-item:hover { background: rgba(214,92,255,0.05); }
[data-theme="crescent"] .logo { font-family: 'Cinzel', serif; letter-spacing: 4px; }
[data-theme="crescent"] .clock { font-family: 'Cinzel', serif; }
[data-theme="crescent"] .board-title { letter-spacing: 2px; }
[data-theme="crescent"] .bm-item:hover { background: rgba(200,168,74,0.05); }
[data-theme="crescent"] .board { border-color: rgba(200,168,74,0.10); }
[data-theme="event-horizon"] .board { border-color: rgba(157, 92, 255, 0.15); }
[data-theme="event-horizon"] .bm-item:hover { background: rgba(157, 92, 255, 0.08); }
[data-theme="merchantman"] .board { border-color: rgba(46, 184, 184, 0.12); }
[data-theme="merchantman"] .bm-item:hover { background: rgba(46, 184, 184, 0.06); }
[data-theme="julia-jin"] .logo { font-family: 'Cinzel', serif; letter-spacing: 5px; text-transform: uppercase; }
[data-theme="julia-jin"] .clock { font-family: 'Cinzel', serif; font-weight: 600; }
[data-theme="julia-jin"] .board-title { letter-spacing: 2px; font-weight: 500; }
[data-theme="julia-jin"] .board { border-color: rgba(125, 179, 255, 0.15); backdrop-filter: blur(4px); }
[data-theme="julia-jin"] .bm-item:hover { background: rgba(125, 179, 255, 0.08); }
[data-theme="sc-sunset"] .board {
border-color: rgba(255, 140, 61, 0.15);
backdrop-filter: blur(6px);}
[data-theme="sc-sunset"] .bm-item:hover {
background: rgba(255, 140, 61, 0.08); }
[data-theme="hellion-hud"] .board {
border-color: rgba(50, 255, 106, 0.15);
backdrop-filter: blur(8px) brightness(1.1);
}
[data-theme="hellion-hud"] .clock {
font-family: 'Rajdhani', sans-serif;
font-weight: 700;
text-shadow: 0 0 10px var(--accent-glow);
}
[data-theme="hellion-energy"] .board {
border-color: rgba(30, 255, 142, 0.15);
backdrop-filter: blur(10px) saturate(1.2);
}
[data-theme="hellion-energy"] .bm-item:hover {
background: rgba(30, 255, 142, 0.10);
box-shadow: inset 0 0 10px rgba(30, 255, 142, 0.05);
}
[data-theme="satisfactory"] .logo { font-family: 'Rajdhani', sans-serif; font-weight: 700; letter-spacing: 3px; text-transform: uppercase; }
[data-theme="satisfactory"] .clock { font-family: 'Rajdhani', sans-serif; font-weight: 600; color: var(--accent); }
[data-theme="satisfactory"] .board-title { font-family: 'Rajdhani', sans-serif; letter-spacing: 1.5px; text-transform: uppercase; }
[data-theme="satisfactory"] .board { border-color: rgba(0, 180, 216, 0.20); backdrop-filter: blur(12px); }
[data-theme="satisfactory"] .bm-item:hover { background: rgba(0, 180, 216, 0.10); }
[data-theme="avorion"] .logo { font-family: 'Rajdhani', sans-serif; font-weight: 500; letter-spacing: 6px; text-transform: uppercase; }
[data-theme="avorion"] .clock { font-family: 'Rajdhani', sans-serif; font-weight: 400; color: var(--accent); }
[data-theme="avorion"] .board-title { font-family: 'Rajdhani', sans-serif; font-weight: 500; text-transform: uppercase; }
[data-theme="avorion"] .board { border-color: rgba(46, 196, 160, 0.15); backdrop-filter: blur(8px); }
[data-theme="avorion"] .bm-item:hover { background: rgba(46, 196, 160, 0.08); }
[data-theme="hellion-stealth"] .logo { font-family: 'Rajdhani', sans-serif; font-weight: 700; letter-spacing: 4px; }
[data-theme="hellion-stealth"] .clock { font-family: 'Rajdhani', sans-serif; font-weight: 600; color: var(--accent); }
[data-theme="hellion-stealth"] .board-title { text-transform: uppercase; font-size: 0.85rem; letter-spacing: 2px; }
[data-theme="hellion-stealth"] .board { border-color: rgba(94, 194, 255, 0.15); backdrop-filter: blur(10px); }
[data-theme="hellion-stealth"] .bm-item:hover { background: rgba(94, 194, 255, 0.10); border-left: 2px solid var(--accent); }
[data-theme="custom"] .board { border-color: color-mix(in srgb, var(--accent) 15%, transparent); }
[data-theme="custom"] .bm-item:hover { background: color-mix(in srgb, var(--accent) 7%, transparent); }
}
/* ============================================
BASE STYLES
============================================ */
@layer base {
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
@@ -457,9 +428,7 @@ html, body {
overflow-x: hidden;
transition: background 0.5s;
}
}
@layer layout {
.bg-layer {
position: fixed; inset: 0; z-index: 0;
background-size: cover; background-position: center;
@@ -493,7 +462,7 @@ html, body {
.logo {
font-family: var(--font-display);
font-size: clamp(0.875rem, 0.8125rem + 0.21vw, 1.0625rem); font-weight: 700; letter-spacing: 3px;
font-size: 17px; font-weight: 700; letter-spacing: 3px;
color: var(--accent);
text-shadow: 0 0 24px var(--logo-shadow);
transition: color 0.5s, text-shadow 0.5s, font-family 0.1s;
@@ -506,7 +475,7 @@ html, body {
.clock {
font-family: var(--font-display);
font-size: clamp(1rem, 0.9167rem + 0.28vw, 1.25rem); font-weight: 500; letter-spacing: 2px;
font-size: 20px; font-weight: 500; letter-spacing: 2px;
color: var(--text-secondary);
transition: color 0.5s, font-family 0.1s;
line-height: 1;
@@ -539,29 +508,15 @@ html, body {
/* BOARDS */
.boards-wrapper {
position: relative; z-index: 10;
/* Free-Layout: Boards liegen absolut (siehe @layer components .board).
Der Flex-Modus bleibt als Fallback fuer den Mobil-Reset erhalten
(dort wird .board wieder position:static, dann greift flex-wrap). */
display: flex; flex-wrap: wrap;
align-content: flex-start;
justify-content: flex-start;
justify-content: center;
gap: 14px;
padding: 110px 40px 40px;
/* Absolute Kinder beanspruchen keinen Platz -> Wrapper braucht eine
explizite Mindesthoehe, sonst kollabiert er auf die Padding-Hoehe. */
min-height: 100vh;
}
}
@layer components {
.board {
/* Freies Layout: Position kommt aus --board-x/--board-y (von drag.js gesetzt),
NICHT inline, damit der ungeschichtete Mobil-@media-Block sie ueberschreiben
kann (Inline-Style wuerde gegen das Stylesheet gewinnen). Default 0/0 fuer
den Fall, dass die Migration noch nicht gelaufen ist. */
position: absolute;
left: var(--board-x, 0px);
top: var(--board-y, 0px);
width: var(--board-width);
background: var(--bg-board);
border: 1px solid var(--border);
@@ -594,22 +549,13 @@ html, body {
flex-shrink: 0;
transition: color 0.15s;
touch-action: none;
/* Ueber dem Blur-Overlay (.board-blur-overlay, z-index:5) liegen, sonst schluckt das
Overlay den pointerdown und ein geblurrtes Board ist nicht verschiebbar. Der Rest des
Boards bleibt unter dem Overlay -> Klick dort enthuellt weiterhin (Phase-5-Review). */
position: relative;
z-index: 6;
}
.board-drag-handle:hover { color: var(--accent); }
.board-drag-handle:active { cursor: grabbing; }
/* Gesperrtes Board (Position fixiert): Drag-Handle ausblenden (kein Verschieben mehr),
Lock-Button aktiv einfaerben. Hoehere Spezifitaet als .board-drag-handle -> display:none gewinnt. */
.board.locked .board-drag-handle { display: none; }
.board.locked .btn-lock-board { color: var(--accent); }
.board-title {
font-family: var(--font-display);
font-size: clamp(0.6875rem, 0.6458rem + 0.14vw, 0.8125rem); font-weight: 600; letter-spacing: 1.5px;
font-size: 13px; font-weight: 600; letter-spacing: 1.5px;
color: var(--accent); text-transform: uppercase;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
max-width: 160px;
@@ -740,43 +686,6 @@ body.show-desc .bm-desc { display: block; }
border-radius: 4px; color: var(--text-muted); white-space: nowrap;
}
/* ---- PAPIERKORB (Trash, v2.3) ---- */
.trash-list { padding: 4px 0; }
.trash-empty {
padding: 12px 18px; font-size: 12px; color: var(--text-muted);
}
/* Vertikale Karte pro Eintrag: Info ueber volle Breite, Aktionen rechtsbuendig darunter.
Verhindert das Zusammenquetschen im schmalen 380px-Settings-Panel. */
.trash-item {
display: flex; flex-direction: column; gap: 8px;
padding: 10px 18px;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.trash-item:last-child { border-bottom: none; }
.trash-item-info { min-width: 0; }
.trash-item-title {
display: block; font-size: 13px; color: var(--text-primary);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.trash-item-meta {
display: block; font-size: 11px; color: var(--text-muted); margin-top: 3px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.trash-item-badge {
display: inline-block; vertical-align: middle;
font-size: 9px; padding: 2px 6px; border-radius: 4px;
background: rgba(255,255,255,0.06); border: 1px solid var(--border);
color: var(--text-muted); white-space: nowrap; margin-right: 6px;
text-transform: uppercase; letter-spacing: 0.5px;
}
.trash-item-actions { display: flex; gap: 6px; justify-content: flex-end; }
/* Per-Eintrag-Buttons kompakt: das volle Uppercase-btn-danger ist pro Eintrag zu wuchtig. */
.trash-item-actions .btn-danger {
padding: 5px 10px; font-size: 11px; letter-spacing: 0.5px;
}
.trash-actions { justify-content: flex-end; }
.trash-actions.hidden { display: none; }
/* THEME PICKER */
.theme-grid {
display: grid; grid-template-columns: 1fr 1fr 1fr 1fr;
@@ -928,13 +837,17 @@ body.show-desc .bm-desc { display: block; }
.modal-body { padding: 14px 16px; }
.modal-footer { padding: 10px 16px 14px; display: flex; justify-content: flex-end; }
/* Free-Move: das gezogene Board nach vorne heben (frueher Reorder-Opacity). */
.board.dragging { z-index: 50; cursor: grabbing; }
.board.dragging { opacity: 0.35; }
.board-placeholder {
border: 2px dashed var(--border-accent);
border-radius: var(--radius);
background: var(--accent-dim);
flex-shrink: 0;
}
/* ---- BOARD BLUR (Private Mode) ---- */
@layer utilities {
.board.blurred .board-list,
.board.blurred .show-more-btn,
.board.blurred .add-bm-btn {
@@ -945,10 +858,9 @@ body.show-desc .bm-desc { display: block; }
.board.blurred .board-title {
filter: blur(5px);
}
/* HINWEIS: KEIN `.board.blurred { position: relative }` — .board ist im Free-Layout bereits
position:absolute (= positionierter Containing-Block fuers Overlay). Ein relative hier liegt
im @layer utilities und wuerde das absolute schlagen -> geblurrtes Board faellt aus seiner
freien Position + waere nicht mehr drag-bar (Phase-5-Review). */
.board.blurred {
position: relative;
}
.board-blur-overlay {
display: none;
position: absolute; inset: 0; z-index: 5;
@@ -978,10 +890,8 @@ body.show-desc .bm-desc { display: block; }
}
.btn-blur-board:hover { background: var(--accent-dim); color: var(--accent); }
.board.blurred .btn-blur-board { color: var(--accent); opacity: 0.7; }
}
@layer components {
/* ---- ABOUT BLOCK ---- */
.about-block {
padding: 4px 18px 14px;
@@ -2413,35 +2323,6 @@ body.show-desc .bm-desc { display: block; }
.theme-modal-section .setting-row {
padding: 8px 0;
}
.bg-url-hint {
padding: 2px 0 6px;
line-height: 1.4;
}
.theme-card-custom-swatch {
width: 100%; height: 56px; border-radius: 8px;
border: 1.5px dashed var(--accent);
background: repeating-linear-gradient(45deg, rgba(255,255,255,0.03) 0 6px, rgba(255,255,255,0.06) 6px 12px);
}
.theme-builder-panel { padding: 10px 0 4px; }
.theme-builder-panel.hidden { display: none; }
.tb-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 18px; }
.tb-picker { display: flex; align-items: center; gap: 9px; }
.tb-picker input[type="color"] {
width: 30px; height: 30px; padding: 0; border: 1px solid var(--border);
border-radius: 6px; background: none; cursor: pointer;
}
.tb-picker label { font-size: 12px; color: var(--text-secondary); }
.tb-contrast { margin-top: 12px; font-size: 12px; display: flex; align-items: center; gap: 8px; }
.tb-contrast .tb-dot { width: 10px; height: 10px; border-radius: 50%; flex: 0 0 auto; }
.tb-contrast.good { color: #7bd88f; } .tb-contrast.good .tb-dot { background: #3fbf6f; }
.tb-contrast.ok { color: #e3c97a; } .tb-contrast.ok .tb-dot { background: #d8b24a; }
.tb-contrast.bad { color: #e58f8f; } .tb-contrast.bad .tb-dot { background: #d65c5c; }
.tb-foot { display: flex; justify-content: flex-end; margin-top: 10px; }
.tb-reset {
font-size: 11px; color: var(--text-secondary); background: none;
border: 1px solid var(--border); border-radius: 6px; padding: 5px 12px; cursor: pointer;
}
@media (max-width: 480px) { .tb-grid { grid-template-columns: 1fr; } }
/* ============================================
ACCORDION SETTINGS
@@ -2462,113 +2343,23 @@ body.show-desc .bm-desc { display: block; }
.settings-section.open .section-content {
max-height: 800px;
}
/* ---- COMMAND-PALETTE (Strg+K) ---- */
/* .palette-overlay erbt .dialog-overlay (Position/Backdrop/Fade/z-index:400). */
.palette-overlay {
align-items: flex-start;
}
.palette-box {
margin-top: 12vh;
width: 560px; max-width: 92vw;
background: rgba(10,9,20,0.98);
border: 1px solid var(--border);
border-radius: var(--radius);
backdrop-filter: blur(28px);
box-shadow: 0 20px 60px rgba(0,0,0,0.7);
transform: translateY(8px) scale(0.98);
transition: transform 0.2s;
overflow: hidden;
}
.dialog-overlay.active .palette-box { transform: translateY(0) scale(1); }
.palette-input {
width: 100%;
box-sizing: border-box;
padding: 16px 18px;
background: transparent;
border: none;
border-bottom: 1px solid var(--border);
color: var(--text-primary);
font-family: var(--font-body);
font-size: 15px;
outline: none;
}
.palette-input::placeholder { color: var(--text-muted); }
.palette-list {
list-style: none;
margin: 0; padding: 6px;
max-height: 46vh;
overflow-y: auto;
}
.palette-option {
display: flex; flex-direction: column; gap: 2px;
padding: 9px 12px;
border-radius: var(--radius-sm);
cursor: pointer;
}
.palette-option[aria-selected="true"] {
background: var(--accent-dim);
}
.palette-option-title {
color: var(--text-primary);
font-size: 13px; font-weight: 500;
}
.palette-option-meta {
color: var(--text-muted);
font-size: 10px; letter-spacing: 0.3px;
}
.palette-empty {
list-style: none;
padding: 16px 12px;
color: var(--text-muted);
font-size: 13px; text-align: center;
}
.palette-live {
position: absolute;
width: 1px; height: 1px;
overflow: hidden; clip: rect(0 0 0 0);
white-space: nowrap;
}
.palette-hint {
padding: 8px 14px;
border-top: 1px solid var(--border);
color: var(--text-muted);
font-size: 10px; letter-spacing: 0.3px;
}
}
/* ============================================
UTILITY CLASSES
============================================ */
@layer utilities {
.hidden { display: none; }
.accent-text { color: var(--accent); }
.dim { opacity: 0.4; }
.drag-ghost {
position: fixed; opacity: 0.75; pointer-events: none; z-index: 9999;
transform: rotate(1.5deg) scale(1.02);
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
}
.bm-item.drag-over { background: rgba(255,160,50,0.07); }
.bm-item.dragging-source { opacity: 0.4; }
.about-info-label-block { display: block; margin-bottom: 6px; }
.about-link-subtle { color: var(--text-secondary); text-decoration: none; }
.modal-input-spaced { margin-top: 8px; }
}
/* ============================================
A11Y FOCUS RING (v2.2)
============================================ */
@layer utilities {
/* A11y: sichtbarer Fokus-Ring, getoent im Theme-Akzent.
Liegt bewusst im spaeten utilities-Layer, damit er die 9 outline:none-Regeln
aus dem components-Layer ueber die Kaskaden-Ordnung schlaegt (Spezifitaet
zwischen Layern irrelevant). Nur :focus-visible, damit Maus-Klicks keinen Ring zeigen. */
:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 70%, white 30%);
outline-offset: 2px;
border-radius: 3px;
}
/* Bookmarks/Cards: Ring leicht enger, da sie eigene Radien haben */
.bm-item:focus-visible,
.theme-card:focus-visible {
outline-offset: 1px;
}
}
/* ============================================
RESPONSIVE — Mobile & Tablet
@@ -2588,13 +2379,7 @@ body.show-desc .bm-desc { display: block; }
}
.btn-icon { padding: 6px 8px; gap: 0; }
.boards-wrapper { padding: 100px 16px 24px; gap: 10px; justify-content: center; }
/* Free-Layout-Reset: ab Tablet wieder gestapeltes Flex-Layout. Ungeschichtet ->
gewinnt ueber @layer components (.board position:absolute). pos/--board-x/y werden ignoriert. */
.board {
position: static;
left: auto; top: auto;
}
.boards-wrapper { padding: 100px 16px 24px; gap: 10px; }
.settings-panel { width: 320px; }
.theme-grid { grid-template-columns: 1fr 1fr; }
@@ -2625,11 +2410,7 @@ body.show-desc .bm-desc { display: block; }
align-items: stretch;
}
.board {
width: 100%;
position: static;
left: auto; top: auto;
}
.board { width: 100%; }
.settings-panel { width: 100%; }
.theme-grid { grid-template-columns: 1fr 1fr; gap: 6px; }
@@ -2649,22 +2430,3 @@ body.show-desc .bm-desc { display: block; }
.modal { width: calc(100vw - 32px); }
}
/* =============================================
prefers-reduced-motion — UNGESCHICHTET (kein @layer).
Ungeschichtete Regeln gewinnen ueber alle Layer.
Kappt alle Transitions/Animationen inkl. der
View-Transition-Pseudo-Elemente (der *-Selektor
trifft sie nicht zuverlaessig, daher explizit).
============================================= */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
transition: none !important;
animation: none !important;
}
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
+1 -106
View File
@@ -6,18 +6,8 @@
async function init() {
const savedBoards = await Store.get('boards');
const savedSettings = await Store.get('settings');
const savedTrash = await Store.get('trash');
boards = savedBoards ?? getDefaultBoards();
trash = Array.isArray(savedTrash) ? savedTrash : [];
// Auto-Cleanup: Eintraege aelter als 30 Tage verwerfen (TRASH-02). Muss VOR
// renderBoards() laufen, damit der Papierkorb-Stand konsistent ist. Schreibt nur
// zurueck, wenn wirklich etwas entfernt wurde (kein unnoetiger Storage-Write).
const cutoff = Date.now() - TRASH_RETENTION_MS;
const beforeCount = trash.length;
trash = trash.filter(entry => entry && typeof entry.deletedAt === 'number' && Number.isFinite(entry.deletedAt) && entry.deletedAt >= cutoff);
if (trash.length !== beforeCount) await saveTrash();
if (savedSettings) Object.assign(settings, savedSettings);
I18n.init();
@@ -26,11 +16,7 @@ async function init() {
startClock();
bindGlobalEvents();
bindSettingsEvents();
bindStorageSync();
bindBoardResizeReclamp(); // Boards bei Fenster-Verkleinerung wieder in den sichtbaren Bereich holen
await drainQuickSavePending(); // beim Start angesammelte Quick-Saves (kein Tab war offen) einlesen
initSearch();
initPalette();
await migrateSticky();
await Notes.init();
await Calculator.init();
@@ -119,7 +105,7 @@ async function checkBackupReminder() {
const notesData = (widgetData && Array.isArray(widgetData.notes)) ? widgetData.notes : [];
const calcHistory = (widgetData && widgetData.calculator) ? widgetData.calculator.history || [] : [];
const timerPresets = (widgetData && widgetData.timer) ? widgetData.timer.presets || [] : [];
const data = { version: '2.4.0', exported: new Date().toISOString(), boards, settings, trash, notes: notesData, calculator: calcHistory, timerPresets };
const data = { version: '2.1.0', exported: new Date().toISOString(), boards, settings, notes: notesData, calculator: calcHistory, timerPresets };
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -234,95 +220,4 @@ function bindGlobalEvents() {
});
}
// ---- QUICK-SAVE PENDING-QUEUE ----
// Der Background-Worker haengt Quick-Saves an den eigenen Store-Key 'quicksave_pending' an (er
// schreibt NIE boards). Diese Seite ist die einzige boards-Schreiberin und drained die Queue in die
// Inbox. Getrennte Schreib-Domaenen -> Worker und Seite koennen sich nicht im boards-Array
// gegenseitig ueberschreiben (Datensicherheit, Phase-4-Review-Blocker 2b).
let _drainBusy = false;
let _drainQueued = false; // ein waehrend eines laufenden Drains angefragter Drain wird nachgeholt
let _renderDeferredByDrag = false; // Drain hat den Render wegen eines laufenden Drags ausgelassen -> nach Drag-Ende nachholen
async function drainQuickSavePending() {
if (_drainBusy) { _drainQueued = true; return; }
_drainBusy = true;
try {
const pending = await Store.get('quicksave_pending');
if (Array.isArray(pending) && pending.length > 0) {
const drained = pending.slice();
const drainedIds = new Set(drained.map(e => e && e.id).filter(Boolean));
const inbox = await ensureInboxBoard(); // legt die Inbox an, falls noetig; gibt das Board zurueck
// Idempotenz gegen den Worker/Drain-Race auf 'quicksave_pending': jede eingespielte Inbox-
// Bookmark traegt die Pending-id ihres Ursprungs als srcId. Taucht ein bereits gedrainter
// Eintrag durch einen gleichzeitigen Worker-Append erneut in der Queue auf, wird er hier
// uebersprungen statt doppelt eingefuegt — kein Duplikat, und kein Verlust (boards-Write
// bleibt vor der Queue-Bereinigung, daher keine umgekehrte Verlustgefahr).
const seenSrc = new Set(inbox.bookmarks.map(b => b && b.srcId).filter(Boolean));
for (const e of drained) {
if (!e || !e.id || seenSrc.has(e.id)) continue; // schon eingespielt
if (typeof e.url !== 'string' || !e.url || !isSafeUrl(e.url)) continue; // leeres/unsicheres Protokoll verwerfen
const bm = normalizeBookmark({ title: e.title, url: e.url });
bm.srcId = e.id; // Herkunft fuer kuenftige Dedup
inbox.bookmarks.push(bm);
seenSrc.add(e.id);
}
await saveBoards();
// NUR die verarbeiteten Eintraege entfernen — ein gleichzeitiger Worker-Append bleibt erhalten.
const still = await Store.get('quicksave_pending');
const remaining = Array.isArray(still) ? still.filter(e => e && !drainedIds.has(e.id)) : [];
await Store.set('quicksave_pending', remaining);
// Render nur, wenn gerade KEIN Drag laeuft (renderBoards->replaceChildren wuerde ihn abreissen).
// Laeuft einer, den Render-Wunsch merken und nach Drag-Ende nachholen (drag.js ruft
// flushQuickSaveRenderIfDeferred), sonst bliebe der frisch gedrainte Quick-Save bis zu einem
// unabhaengigen Fremd-Render unsichtbar (Phase-6-Review).
if (document.querySelector('.board.dragging, .bm-item.dragging-source')) {
_renderDeferredByDrag = true;
} else {
renderBoards();
}
}
} catch (e) {
console.error('Quick-Save-Drain fehlgeschlagen:', e && e.message);
} finally {
_drainBusy = false;
}
// Kam waehrend des Drains ein weiterer Quick-Save an (onChanged wurde durch _drainBusy verworfen),
// jetzt nachholen. Der Eintrag war sicher in der Queue, nur noch nicht eingelesen.
if (_drainQueued) { _drainQueued = false; drainQuickSavePending(); }
}
// Wird von drag.js nach jedem Drag-Ende aufgerufen: einen waehrend des Drags ausgelassenen
// Quick-Save-Render nachholen. Idempotent — tut nichts, wenn kein Render aussteht.
function flushQuickSaveRenderIfDeferred() {
if (_renderDeferredByDrag) {
_renderDeferredByDrag = false;
renderBoards();
}
}
// Live-Sync (QS-03): ein offener NewTab drained die Queue, sobald der Worker etwas anhaengt.
function bindStorageSync() {
if (typeof chrome === 'undefined' || !chrome.storage || !chrome.storage.onChanged) return;
chrome.storage.onChanged.addListener((changes, area) => {
// Nur auf die Quick-Save-Queue reagieren — 'boards' schreibt ausschliesslich diese Seite.
if (area !== 'local' || !changes.quicksave_pending) return;
drainQuickSavePending();
});
}
// Freies Layout (LAYOUT-04): Boards stehen absolut positioniert. Schrumpft das Fenster, koennen
// sie ganz aus dem sichtbaren Bereich rutschen. renderBoards() klemmt die --board-x/--board-y jedes
// Boards beim Aufbau gegen die aktuelle Viewport — ein simpler Re-Render holt sie also zurueck.
// Debounce (150ms), damit kontinuierliches Resizen nicht hunderte Renders ausloest. Waehrend eines
// aktiven Drags NICHT neu rendern: renderBoards->replaceChildren wuerde den laufenden Drag abreissen.
function bindBoardResizeReclamp() {
let resizeTimer = null;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (document.querySelector('.board.dragging, .bm-item.dragging-source')) return;
renderBoards();
}, 150);
});
}
document.addEventListener('DOMContentLoaded', init);
-82
View File
@@ -1,82 +0,0 @@
/* =============================================
HELLION NEWTAB — background.js
Quick-Save Background fuer Chrome (Service-Worker)
UND Firefox (Event-Page). Kein DOM/window. Listener
synchron auf Top-Level. Geteilte Logik via importScripts.
============================================= */
// Geteiltes DOM-freies Helfer-Modul aus Phase 1: ensureInbox(boards), uid(), normalizeBookmark(...).
// Chrome-Service-Worker laedt es via importScripts. Firefox-Event-Page hat KEIN importScripts —
// dort kommt das Modul ueber background.scripts (manifest.firefox.json) in den Scope, ensureInbox
// ist dann schon definiert. Der Guard verhindert den ReferenceError in der Event-Page.
if (typeof importScripts === 'function' && typeof ensureInbox === 'undefined') {
importScripts('quicksave-core.js');
}
// chrome.storage.local-Lese-/Schreib-Helfer als Promises (kein Store-Modul im Worker,
// das ist DOM/Seiten-gebunden). Identisches Verhalten: get -> Wert oder null.
function bgGet(key) {
return new Promise(resolve => {
chrome.storage.local.get([key], r => resolve(r[key] ?? null));
});
}
function bgSet(key, value) {
return new Promise((resolve, reject) => {
chrome.storage.local.set({ [key]: value }, () => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
resolve();
});
});
}
// Kurze Badge-Bestaetigung, dann automatisch wieder leeren. color optional (Default gruen).
function flashBadge(text, color) {
chrome.action.setBadgeText({ text });
// Hintergrundfarbe optional, ohne extra Permission moeglich.
if (chrome.action.setBadgeBackgroundColor) {
chrome.action.setBadgeBackgroundColor({ color: color || '#1f9d55' });
}
setTimeout(() => chrome.action.setBadgeText({ text: '' }), 2000);
}
// Interne/nicht speicherbare Seiten (Browser-UI, Extension-Seiten) — kein sinnvolles Bookmark.
const UNSAVEABLE_URL = /^(chrome|chrome-extension|about|edge|opera|moz-extension|brave|vivaldi|view-source|devtools):/i;
// Quick-Save: aktiven Tab in die Pending-Queue haengen — NICHT boards schreiben.
// Datensicherheit (Phase-4-Review 2b): boards schreibt ausschliesslich die NewTab-Seite. Der Worker
// haengt nur an 'quicksave_pending' an; die Seite drained die Queue in die Inbox. So koennen Worker
// und Seite sich nicht im boards-Array gegenseitig ueberschreiben (kein Lost-Update bestehender Daten).
async function quickSaveActiveTab() {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tab = tabs && tabs[0];
if (!tab || !tab.url || UNSAVEABLE_URL.test(tab.url)) {
// Kein speicherbarer Tab: kurzer roter Marker (langer Text wird im Badge abgeschnitten).
flashBadge('×', '#c0392b');
return;
}
try {
// read-modify-write nur auf der EIGENEN Queue (bgGet/bgSet sind via quickSaveChain serialisiert).
const pending = (await bgGet('quicksave_pending')) ?? [];
pending.push({ id: uid(), title: tab.title || tab.url, url: tab.url });
await bgSet('quicksave_pending', pending);
flashBadge(chrome.i18n.getMessage('quickSaveBadge'));
} catch (e) {
// Quota o.ae.: Badge zeigt nichts Gruenes, Fehler in die Worker-Konsole.
console.error('Quick-Save fehlgeschlagen:', e.message);
}
}
// Quick-Saves serialisieren: zwei schnelle Tastendruecke (Key-Repeat) wuerden sonst parallel
// read-modify-write machen und sich gegenseitig ueberschreiben (lost update). Promise-Kette
// sorgt fuer sequentielle Ausfuehrung. Listener bleibt SYNCHRON auf Top-Level registriert.
let quickSaveChain = Promise.resolve();
chrome.commands.onCommand.addListener(command => {
if (command === 'quick-save') {
quickSaveChain = quickSaveChain.then(() => quickSaveActiveTab()).catch(e => console.error('Quick-Save:', e && e.message));
}
});
+8 -127
View File
@@ -46,34 +46,6 @@ function createPlusSvg() {
]);
}
/** Erzeugt das Pin-/Reisszwecke-Icon SVG (Position fixieren) — bewusst KEIN Emoji (custom SVG). */
function createPinSvg() {
return svgEl('svg', { width: '11', height: '12', viewBox: '0 0 24 24', fill: 'currentColor' }, [
svgEl('path', { d: 'M16 9V4l1 0c.55 0 1-.45 1-1s-.45-1-1-1H7c-.55 0-1 .45-1 1s.45 1 1 1l1 0v5c0 1.66-1.34 3-3 3v2h5.97v7l1 1 1-1v-7H19v-2c-1.66 0-3-1.34-3-3z' }),
]);
}
// ---- POS-MIGRATION ----
// Boards ohne pos (Altbestand vor v2.3) aus einem Auto-Raster befuellen,
// damit sie sich nicht alle auf (0,0) stapeln. Raster orientiert sich am
// Wrapper-Padding (110px oben / 40px links) und der Board-Breite.
function ensureBoardPositions() {
const COL_W = 240 + 14; // --board-width (Desktop) + gap
const ROW_H = 220; // grober Board-Hoehen-Schaetzwert fuers Auto-Raster
const startX = 40, startY = 110;
const cols = Math.max(1, Math.floor((window.innerWidth - startX * 2 + 14) / COL_W));
let migrated = false;
boards.forEach((board, i) => {
if (board.pos && typeof board.pos.x === 'number' && typeof board.pos.y === 'number') return;
const col = i % cols;
const row = Math.floor(i / cols);
board.pos = { x: startX + col * COL_W, y: startY + row * ROW_H };
migrated = true;
});
return migrated;
}
// ---- RENDER ----
function renderBoards() {
const wrapper = document.getElementById('boardsWrapper');
@@ -98,29 +70,13 @@ function renderBoards() {
return;
}
// Altbestand ohne pos migrieren (Auto-Raster), danach einmalig speichern.
const migrated = ensureBoardPositions();
boards.forEach(board => {
const el = createBoardEl(board);
wrapper.appendChild(el);
// Position als Custom-Property setzen (nicht inline left/top), damit der Mobil-@media-Reset
// sie ueberschreiben kann. Gegen den AKTUELLEN Viewport clampen, damit ein auf breiterem
// Fenster platziertes Board nie off-screen (und damit per Drag unerreichbar) rendert.
// board.pos bleibt unveraendert -> bei spaeterer Verbreiterung wird die Originalposition wieder erreicht.
const cx = Math.max(0, Math.min(window.innerWidth - el.offsetWidth, board.pos.x));
const cy = Math.max(48, Math.min(window.innerHeight - el.offsetHeight, board.pos.y));
el.style.setProperty('--board-x', cx + 'px');
el.style.setProperty('--board-y', cy + 'px');
});
boards.forEach(board => wrapper.appendChild(createBoardEl(board)));
initBoardDragDrop();
if (migrated) saveBoards();
}
function createBoardEl(board) {
const div = document.createElement('div');
div.className = 'board' + (board.blurred ? ' blurred' : '') + (board.locked ? ' locked' : '');
div.className = 'board' + (board.blurred ? ' blurred' : '');
div.dataset.boardId = board.id;
// Header
@@ -140,11 +96,6 @@ function createBoardEl(board) {
const actions = document.createElement('div');
actions.className = 'board-actions';
const btnLock = document.createElement('button');
btnLock.className = 'board-action-btn btn-lock-board';
btnLock.title = board.locked ? t('boards.unlock') : t('boards.lock');
btnLock.appendChild(createPinSvg());
const btnBlur = document.createElement('button');
btnBlur.className = 'board-action-btn btn-blur-board';
btnBlur.title = board.blurred ? t('boards.unblur') : t('boards.blur');
@@ -155,19 +106,12 @@ function createBoardEl(board) {
btnRename.title = t('boards.rename');
btnRename.textContent = '\u270E';
// Das feste Inbox-Board (Quick-Save-Ziel) darf nicht geloescht werden \u2014 kein Delete-Button.
const btnDelete = board.id === 'inbox' ? null : document.createElement('button');
if (btnDelete) {
const btnDelete = document.createElement('button');
btnDelete.className = 'board-action-btn btn-delete-board';
btnDelete.title = t('boards.delete');
btnDelete.textContent = '\u2715';
}
if (btnDelete) {
actions.append(btnLock, btnBlur, btnRename, btnDelete);
} else {
actions.append(btnLock, btnBlur, btnRename);
}
actions.append(btnBlur, btnRename, btnDelete);
header.append(dragHandle, titleSpanHeader, actions);
// Blur-Overlay
@@ -175,16 +119,6 @@ function createBoardEl(board) {
blurOverlay.className = 'board-blur-overlay';
div.appendChild(blurOverlay);
btnLock.addEventListener('click', async e => {
e.stopPropagation();
// Position fixieren: blendet via .board.locked den Drag-Handle aus (CSS) und der onDown-Guard
// in drag.js verweigert zusaetzlich den Drag. Reiner Klassen-Toggle, kein Re-Render noetig.
board.locked = !board.locked;
div.classList.toggle('locked', board.locked);
btnLock.title = board.locked ? t('boards.unlock') : t('boards.lock');
await saveBoards();
});
btnBlur.addEventListener('click', async e => {
e.stopPropagation();
board.blurred = !board.blurred;
@@ -210,29 +144,15 @@ function createBoardEl(board) {
});
});
if (btnDelete) btnDelete.addEventListener('click', async e => {
btnDelete.addEventListener('click', async e => {
e.stopPropagation();
const ok = await HellionDialog.confirm(
t('boards.delete_confirm', { title: board.title }),
{ type: 'danger', title: t('boards.delete_confirm.title'), confirmText: t('boards.delete') }
);
if (ok) {
// Ganzes board-Objekt (inkl. bookmarks UND blurred-Flag, CR-01) in den Papierkorb.
// type:'board', kein originBoardId (Board hat keine Herkunft, Restore legt es direkt in boards[]).
// Datensicherheit: ZUERST Trash sichern (saveTrash), DANN Loeschung committen (saveBoards) —
// bei Quota-Reject bleibt das Board in boards[], kein Datenverlust.
const trashEntry = pushToTrash({ item: board, type: 'board', originBoardId: null });
try {
await saveTrash();
boards = boards.filter(b => b.id !== board.id);
await saveBoards();
} catch (err) {
// Save fehlgeschlagen (z.B. Quota genau zwischen den Writes): auf den Vor-Loesch-Stand
// zurueckrollen, damit In-Memory und Storage konsistent bleiben (kein Reload-Duplikat).
trash = trash.filter(t => t !== trashEntry);
if (!boards.some(b => b.id === board.id)) boards.push(board);
console.error('Board-Loeschen fehlgeschlagen, zurueckgerollt:', err && err.message);
}
renderBoards();
}
});
@@ -294,9 +214,6 @@ function createBmEl(bm) {
li.dataset.bmId = bm.id;
li.dataset.bmUrl = bm.url;
li.draggable = true;
li.setAttribute('role', 'link');
li.setAttribute('tabindex', '0');
li.setAttribute('aria-label', bm.title);
const favicon = document.createElement('div');
favicon.className = 'bm-favicon-local';
@@ -334,28 +251,12 @@ function bindBoardListEvents(list, board) {
const bmItem = e.target.closest('.bm-item');
if (!bmItem) return;
// Delete-Button geklickt: kein Confirm (wie bisher), aber nicht mehr hart loeschen —
// das Bookmark wandert in den Papierkorb (30 Tage, TRASH-01). Erst per find() greifen,
// dann mit Herkunft (originBoardId), type und Zeitstempel ins trash[] pushen.
// Delete-Button geklickt
if (e.target.closest('.bm-delete')) {
e.stopPropagation();
const bmId = bmItem.dataset.bmId;
const removed = board.bookmarks.find(b => b.id === bmId);
if (removed) {
// Datensicherheit: ZUERST den Trash-Klon persistieren, DANN die Loeschung committen.
// Falls saveTrash() (Quota) rejectet, ist das Original noch in boards[] -> kein Verlust.
const trashEntry = pushToTrash({ item: removed, type: 'bookmark', originBoardId: board.id });
try {
await saveTrash();
board.bookmarks = board.bookmarks.filter(b => b.id !== bmId);
await saveBoards();
} catch (err) {
// Save fehlgeschlagen: auf den Vor-Loesch-Stand zurueckrollen (kein Reload-Duplikat).
trash = trash.filter(t => t !== trashEntry);
if (!board.bookmarks.some(b => b.id === bmId)) board.bookmarks.push(removed);
console.error('Bookmark-Loeschen fehlgeschlagen, zurueckgerollt:', err && err.message);
}
}
renderBoards();
return;
}
@@ -366,31 +267,11 @@ function bindBoardListEvents(list, board) {
window.open(url, settings.newTab ? '_blank' : '_self', 'noopener,noreferrer');
}
});
// Tastatur: Enter oeffnet den Bookmark wie ein Klick. role="link" erwartet
// nur Enter (Space ist Button-Semantik). Der Delete-Button bleibt ein echter
// <button> und feuert seinen eigenen Klick ueber Space/Enter selbst.
list.addEventListener('keydown', e => {
if (e.key !== 'Enter') return;
const bmItem = e.target.closest('.bm-item');
if (!bmItem || e.target !== bmItem) return; // nur wenn der li selbst fokussiert ist
e.preventDefault();
const url = bmItem.dataset.bmUrl;
if (url) {
window.open(url, settings.newTab ? '_blank' : '_self', 'noopener,noreferrer');
}
});
}
// ---- MODALS ----
// reduced-motion kappt das Fade ueber den ungeschichteten @media-Block.
// Feature-Detection-Fallback (Firefox < 144): instant.
function openModal(id) {
withViewTransition(() => document.getElementById(id).classList.add('active'));
}
function closeModal(id) {
withViewTransition(() => document.getElementById(id).classList.remove('active'));
}
function openModal(id) { document.getElementById(id).classList.add('active'); }
function closeModal(id) { document.getElementById(id).classList.remove('active'); }
function openAddBoardModal() {
document.getElementById('newBoardName').value = '';
+3 -5
View File
@@ -196,18 +196,16 @@ const BrowserBookmarkImport = {
overlay.appendChild(modal);
document.body.appendChild(overlay);
// View-Transition-Fade
withViewTransition(() => overlay.classList.add('active'));
// Animation
requestAnimationFrame(() => overlay.classList.add('active'));
},
/** Schliesst das Modal */
_closeModal() {
const overlay = document.getElementById('bmImportOverlay');
if (!overlay) return;
withViewTransition(() => {
overlay.classList.remove('active');
overlay.remove();
});
setTimeout(() => overlay.remove(), 250);
},
/**
+3 -67
View File
@@ -24,26 +24,14 @@ function initDataButtons() {
}
}
/**
* Validiert eine freie Layout-Position (LAYOUT-04). Liefert { x, y } nur bei
* endlichen Zahlen, sonst null — dann gridded ensureBoardPositions das Board neu.
* Ohne das wuerde ein Import jede vom Nutzer gesetzte Board-Position verwerfen.
* @param {*} pos
* @returns {{x:number,y:number}|null}
*/
function safePos(pos) {
return pos && Number.isFinite(pos.x) && Number.isFinite(pos.y) ? { x: pos.x, y: pos.y } : null;
}
// Export (inkl. Notes)
btnExport.addEventListener('click', async () => {
const widgetData = await Store.get('widgetStates');
const data = {
version: '2.4.0',
version: '2.1.0',
exported: new Date().toISOString(),
boards,
settings,
trash,
notes: widgetData && Array.isArray(widgetData.notes) ? widgetData.notes : [],
calculator: widgetData && widgetData.calculator ? widgetData.calculator.history || [] : [],
timerPresets: widgetData && widgetData.timer ? widgetData.timer.presets || [] : []
@@ -67,12 +55,10 @@ function initDataButtons() {
if (!Array.isArray(data.boards)) throw new Error(t('data.invalid_format'));
const validBoards = data.boards
.filter(b => b && typeof b.title === 'string' && Array.isArray(b.bookmarks))
.map(b => {
const board = {
.map(b => ({
id: b.id || uid(),
title: String(b.title).slice(0, 100),
blurred: !!b.blurred,
locked: !!b.locked,
bookmarks: b.bookmarks
.filter(bm => bm && typeof bm.title === 'string' && isSafeUrl(bm.url))
.map(bm => ({
@@ -81,12 +67,7 @@ function initDataButtons() {
url: bm.url,
desc: String(bm.desc || '').slice(0, 500)
}))
};
// Freies Layout (LAYOUT-04): valide Position uebernehmen, sonst gridded ensureBoardPositions neu.
const pos = safePos(b.pos);
if (pos) board.pos = pos;
return board;
});
}));
if (validBoards.length === 0) throw new Error(t('data.no_boards'));
const ok = await HellionDialog.confirm(
t('data.import_confirm', { count: validBoards.length }),
@@ -97,51 +78,6 @@ function initDataButtons() {
await saveBoards();
renderBoards();
// Papierkorb importieren (falls vorhanden) — defensiv validiert.
if (Array.isArray(data.trash) && data.trash.length > 0) {
const validTrash = data.trash
.filter(e => e && e.item && ['bookmark', 'board'].includes(e.type) && typeof e.deletedAt === 'number' && Number.isFinite(e.deletedAt))
.map(e => ({
type: e.type,
originBoardId: typeof e.originBoardId === 'string' ? e.originBoardId : null,
deletedAt: e.deletedAt,
item: e.type === 'board'
? {
id: e.item.id || uid(),
title: String(e.item.title || '').slice(0, 100),
blurred: !!e.item.blurred,
locked: !!e.item.locked,
// Position erhalten, damit ein wiederhergestelltes Board an seinem Platz landet.
...(safePos(e.item.pos) ? { pos: safePos(e.item.pos) } : {}),
bookmarks: Array.isArray(e.item.bookmarks)
? e.item.bookmarks
.filter(bm => bm && typeof bm.title === 'string' && isSafeUrl(bm.url))
.map(bm => ({ id: bm.id || uid(), title: String(bm.title).slice(0, 200), url: bm.url, desc: String(bm.desc || '').slice(0, 500) }))
.slice(0, 500)
: []
}
: (isSafeUrl(e.item.url)
? { id: e.item.id || uid(), title: String(e.item.title || '').slice(0, 200), url: e.item.url, desc: String(e.item.desc || '').slice(0, 500) }
: null)
}))
.filter(e => e.item !== null);
if (validTrash.length > 0) {
// Lokale Eintraege sind die EINZIGE Kopie ihrer geloeschten Daten -> Vorrang. Importierte
// stammen aus einem Backup, das der Nutzer noch besitzt -> nachrangig. Daher: erst ALLE
// lokalen behalten (pushToTrash kappt sie bereits auf TRASH_MAX_ENTRIES), dann mit den
// NEUESTEN importierten bis zur Obergrenze auffuellen. Ein frischer Import verdraengt so
// keine aelteren lokalen Sole-Copies mehr (frueheres sort+slice(-N) konnte das, data-loss).
const room = Math.max(0, TRASH_MAX_ENTRIES - trash.length);
const keptImports = validTrash
.slice()
.sort((a, b) => b.deletedAt - a.deletedAt) // neueste Importe zuerst
.slice(0, room);
// Am Ende nach deletedAt aufsteigend fuer eine stabile Anzeige-Reihenfolge.
trash = [...trash, ...keptImports].sort((a, b) => a.deletedAt - b.deletedAt);
await saveTrash();
}
}
// Notes importieren (falls vorhanden)
let notesImported = 0;
const existingWidgets = await Store.get('widgetStates') || {};
+4 -30
View File
@@ -40,34 +40,23 @@ const HellionDialog = {
*/
_show(config) {
return new Promise(resolve => {
const prevFocus = document.activeElement;
const overlay = document.createElement('div');
overlay.className = 'dialog-overlay';
const box = document.createElement('div');
box.className = 'dialog-box';
box.setAttribute('role', config.isConfirm ? 'alertdialog' : 'dialog');
box.setAttribute('aria-modal', 'true');
// Eindeutige IDs pro Dialog-Instanz: kurz gestapelte Dialoge (timer.js/
// image-ref.js feuern teils ohne await) duerfen sich keine festen IDs
// teilen, sonst liest der Screenreader ueber aria-* den falschen Titel.
const uid = 'dlg-' + Date.now().toString(36) + '-' + (HellionDialog._seq = (HellionDialog._seq || 0) + 1);
box.setAttribute('aria-labelledby', uid + '-title');
box.setAttribute('aria-describedby', uid + '-body');
// Header
const header = document.createElement('div');
header.className = 'dialog-header';
header.appendChild(this._createIcon(config.type));
const titleSpan = document.createElement('span');
titleSpan.id = uid + '-title';
titleSpan.textContent = config.title;
header.appendChild(titleSpan);
// Body
const body = document.createElement('div');
body.className = 'dialog-body';
body.id = uid + '-body';
body.textContent = config.message;
// Actions
@@ -75,12 +64,9 @@ const HellionDialog = {
actions.className = 'dialog-actions';
function cleanup(result) {
document.removeEventListener('keydown', keyHandler);
withViewTransition(() => {
overlay.classList.remove('active');
overlay.remove();
});
if (prevFocus && typeof prevFocus.focus === 'function') prevFocus.focus();
document.removeEventListener('keydown', keyHandler);
setTimeout(() => overlay.remove(), 200);
resolve(result);
}
@@ -118,24 +104,12 @@ const HellionDialog = {
e.preventDefault();
cleanup(config.isConfirm ? false : undefined);
}
if (e.key === 'Tab') {
// Fokus-Falle: nur die Buttons im actions-Container sind fokussierbar
const items = Array.from(actions.querySelectorAll('button'));
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault(); last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault(); first.focus();
}
}
}
document.addEventListener('keydown', keyHandler);
document.body.appendChild(overlay);
// View-Transition uebernimmt das Fade; Fokus bleibt erhalten
withViewTransition(() => {
// Nächster Frame für CSS-Transition
requestAnimationFrame(() => {
overlay.classList.add('active');
confirmBtn.focus();
});
+72 -71
View File
@@ -5,92 +5,95 @@
Bookmarks: Reihenfolge innerhalb eines Boards
============================================= */
// ---- BOARD FREE-MOVE (Pointer Events) ----
// Neugebaut fuer v2.3 (frueher Reorder mit Ghost/Placeholder). Vorbild:
// widgets.js _initDrag — setPointerCapture, offX/offY, onMove mit Clamping
// gegen window.innerWidth/Height, onUp schreibt board.pos + saveBoards().
// Gebunden am .board-drag-handle, NICHT am ganzen .board, damit Bookmark-Drag,
// Klick-Delegation und Action-Buttons frei bleiben.
// ---- BOARD DRAG (Pointer Events) ----
function initBoardDragDrop() {
const wrapper = document.getElementById('boardsWrapper');
let dragging = null;
let placeholder = null;
function getInsertTarget(clientX, clientY) {
const boardEls = Array.from(wrapper.querySelectorAll('.board:not(.dragging)'));
for (const b of boardEls) {
const r = b.getBoundingClientRect();
if (clientX >= r.left && clientX <= r.right && clientY >= r.top && clientY <= r.bottom) {
return { el: b, before: clientX < r.left + r.width / 2 };
}
}
return null;
}
wrapper.querySelectorAll('.board').forEach(boardEl => {
const handle = boardEl.querySelector('.board-drag-handle');
if (!handle) return;
handle.addEventListener('pointerdown', function onDown(e) {
// Auf Mobil ist .board position:static (Stapel) -> kein Free-Move.
if (getComputedStyle(boardEl).position !== 'absolute') return;
// Gesperrtes Board (Position fixiert, LAYOUT-LOCK) nicht verschieben. Der Drag-Handle ist
// bei .locked schon per CSS ausgeblendet; dieser Guard ist die zweite Sicherung.
if (boardEl.classList.contains('locked')) return;
handle.style.cursor = 'grab';
handle.addEventListener('pointerdown', e => {
e.preventDefault();
handle.setPointerCapture(e.pointerId);
// .board.dragging hebt das Board per CSS nach vorne (z-index) UND ist das Signal fuer den
// Live-Sync-Guard in app.js (bindStorageSync verwirft ein onChanged-Re-Render, das diesen
// Drag sonst abreissen wuerde). Der Guard prueft genau diese Klasse (Phase-4-Review 2a).
boardEl.classList.add('dragging');
handle.style.cursor = 'grabbing';
const rect = boardEl.getBoundingClientRect();
const offX = e.clientX - rect.left;
const offY = e.clientY - rect.top;
const startCX = e.clientX, startCY = e.clientY;
// Erst eine echte Bewegung (> 3px) zaehlt als Drag. Ein reiner Klick/Tap auf den Handle darf
// board.pos NICHT ueberschreiben: renderBoards() schreibt in --board-x/y den gegen die Viewport
// GECLAMPTEN Wert, board.pos bleibt absichtlich der wahre (evtl. off-screen) Wert. onUp liest
// --board-x/y zurueck — bei einem No-Move-Klick waere das der Clamp und wuerde die wahre
// Position zerstoeren (Phase-5-Review, HIGH/data-loss).
let moved = false;
function onMove(ev) {
if (Math.abs(ev.clientX - startCX) > 3 || Math.abs(ev.clientY - startCY) > 3) moved = true;
const maxX = window.innerWidth - boardEl.offsetWidth;
const maxY = window.innerHeight - boardEl.offsetHeight;
const x = Math.max(0, Math.min(maxX, ev.clientX - offX));
const y = Math.max(48, Math.min(maxY, ev.clientY - offY)); // 48px = Header-Hoehe
boardEl.style.setProperty('--board-x', x + 'px');
boardEl.style.setProperty('--board-y', y + 'px');
}
// Ghost
const ghost = boardEl.cloneNode(true);
ghost.className += ' drag-ghost';
ghost.style.left = rect.left + 'px';
ghost.style.top = rect.top + 'px';
ghost.style.width = rect.width + 'px';
ghost.style.height = rect.height + 'px';
document.body.appendChild(ghost);
// Gemeinsames Aufraeumen: Pointer-Capture freigeben, ALLE Listener entfernen,
// .board.dragging entfernen. MUSS auch im Cancel-Pfad laufen — sonst klebt die Klasse
// und der app.js-Sync-Guard unterdrueckt dauerhaft Quick-Save-Renders (Phase-5-Review).
function cleanup() {
try { handle.releasePointerCapture(e.pointerId); } catch (_) { /* schon freigegeben */ }
handle.removeEventListener('pointermove', onMove);
handle.removeEventListener('pointerup', onUp);
handle.removeEventListener('pointercancel', onCancel);
boardEl.classList.remove('dragging');
}
// Placeholder
placeholder = document.createElement('div');
placeholder.className = 'board-placeholder';
placeholder.style.cssText = `width:${rect.width}px; height:${rect.height}px;`;
boardEl.parentNode.insertBefore(placeholder, boardEl);
boardEl.classList.add('dragging');
async function onUp() {
cleanup();
// Nur bei echtem Verschieben persistieren — sonst board.pos unangetastet lassen.
if (moved) {
const id = boardEl.dataset.boardId;
const board = boards.find(b => b.id === id);
if (board) {
board.pos = {
x: parseFloat(boardEl.style.getPropertyValue('--board-x')),
y: parseFloat(boardEl.style.getPropertyValue('--board-y'))
dragging = { el: boardEl, ghost,
offsetX: e.clientX - rect.left,
offsetY: e.clientY - rect.top
};
});
handle.addEventListener('pointermove', e => {
if (!dragging || dragging.el !== boardEl) return;
e.preventDefault();
dragging.ghost.style.left = (e.clientX - dragging.offsetX) + 'px';
dragging.ghost.style.top = (e.clientY - dragging.offsetY) + 'px';
const target = getInsertTarget(e.clientX, e.clientY);
if (target && target.el !== boardEl) {
target.before
? target.el.parentNode.insertBefore(placeholder, target.el)
: target.el.parentNode.insertBefore(placeholder, target.el.nextSibling);
}
});
handle.addEventListener('pointerup', async () => {
if (!dragging || dragging.el !== boardEl) return;
handle.style.cursor = 'grab';
placeholder.parentNode.insertBefore(boardEl, placeholder);
placeholder.remove(); placeholder = null;
boardEl.classList.remove('dragging');
dragging.ghost.remove();
dragging = null;
// Neue Reihenfolge aus DOM ablesen
const newOrder = Array.from(wrapper.querySelectorAll('.board'))
.map(el => el.dataset.boardId).filter(Boolean);
boards.sort((a, b) => newOrder.indexOf(a.id) - newOrder.indexOf(b.id));
await saveBoards();
}
}
// Einen waehrend des Drags ausgelassenen Quick-Save-Render nachholen (app.js).
if (typeof flushQuickSaveRenderIfDeferred === 'function') flushQuickSaveRenderIfDeferred();
}
});
// pointercancel feuert STATT pointerup bei Touch-Interrupt, Browser-Geste oder wenn das
// captured Element aus dem DOM faellt -> nur aufraeumen (cleanup), pos NICHT ueberschreiben.
function onCancel() {
cleanup();
if (typeof flushQuickSaveRenderIfDeferred === 'function') flushQuickSaveRenderIfDeferred();
}
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', onUp);
handle.addEventListener('pointercancel', onCancel);
handle.addEventListener('pointercancel', () => {
if (!dragging) return;
dragging.ghost.remove();
if (placeholder) { placeholder.remove(); placeholder = null; }
boardEl.classList.remove('dragging');
dragging = null;
handle.style.cursor = 'grab';
});
});
}
@@ -110,8 +113,6 @@ function initBookmarkDragDrop(listEl, board) {
listEl.addEventListener('dragend', e => {
const item = e.target.closest('.bm-item');
if (item) item.classList.remove('dragging-source');
// Blieb ein Quick-Save-Render waehrend des Bookmark-Drags aus (Drop ausgeblieben), jetzt nachholen.
if (typeof flushQuickSaveRenderIfDeferred === 'function') flushQuickSaveRenderIfDeferred();
});
listEl.addEventListener('dragover', e => {
+10 -140
View File
@@ -21,8 +21,6 @@ const STRINGS = {
'boards.drag_title': 'Board verschieben',
'boards.blur': 'Blur (privat)',
'boards.unblur': 'Unblur',
'boards.lock': 'Position sperren',
'boards.unlock': 'Position entsperren',
'boards.rename': 'Umbenennen',
'boards.delete': 'Löschen',
'boards.delete_confirm': 'Board „{title}" wirklich löschen?',
@@ -32,26 +30,6 @@ const STRINGS = {
'boards.add_link': ' Link hinzufügen',
'boards.remove_bookmark': 'Entfernen',
// Papierkorb (Trash)
'trash.section': 'PAPIERKORB',
'trash.empty': 'Der Papierkorb ist leer.',
'trash.deleted_at': 'Gelöscht am {date}',
'trash.type.bookmark': 'Lesezeichen',
'trash.type.board': 'Board',
'trash.from_board': 'aus „{board}"',
'trash.from_board_unknown': 'Ursprungs-Board gelöscht',
'trash.restore': 'Wiederherstellen',
'trash.restore_title': 'Wiederherstellen',
'trash.delete_forever': 'Endgültig löschen',
'trash.delete_forever_title': 'Endgültig löschen',
'trash.empty_btn': 'Papierkorb leeren',
'trash.empty_confirm': 'Alle {count} Einträge endgültig löschen? Das kann nicht rückgängig gemacht werden.',
'trash.empty_confirm.title': 'Papierkorb leeren',
'trash.delete_forever_confirm': 'Diesen Eintrag endgültig löschen? Das kann nicht rückgängig gemacht werden.',
'trash.delete_forever_confirm.title': 'Endgültig löschen',
'trash.restored_to_inbox': 'Lesezeichen in die Inbox wiederhergestellt (Ursprungs-Board existiert nicht mehr).',
'trash.restored_to_inbox.title': 'Wiederhergestellt',
// Onboarding
'onboarding.skip': 'Überspringen',
'onboarding.back': 'Zurück',
@@ -66,8 +44,8 @@ const STRINGS = {
'onboarding.s2.f2': 'Importiere Browser-Lesezeichen über den „Import" Button im Header',
'onboarding.s2.f3': 'Drag & Drop zum Umsortieren von Boards und Links',
'onboarding.s2.f4': 'Blur-Modus für private Boards (🔒 Icon)',
'onboarding.s3.title': 'Handgefertigte Themes + dein eigenes',
'onboarding.s3.text': 'Klicke auf den „Theme" Button im Header, um ein Theme zu wählen oder dir ein eigenes zu bauen. Jedes hat seinen eigenen Stil.',
'onboarding.s3.title': '11 handgefertigte Themes',
'onboarding.s3.text': 'Klicke auf den „Theme" Button im Header um dein Theme zu wählen. Jedes hat seinen eigenen Stil und Farbpalette.',
'onboarding.s4.title': 'Widget-Toolbar',
'onboarding.s4.f1': 'Die schwebenden Buttons rechts öffnen Widgets',
'onboarding.s4.f2': 'Notes und Checklisten für schnelle Notizen',
@@ -82,8 +60,6 @@ const STRINGS = {
'onboarding.tradecenter_desc': 'Trade Center für Star Citizen',
'onboarding.s7.title': 'Bereit!',
'onboarding.s7.text': 'Erstelle dein erstes Board mit „+ Board" oder importiere deine Browser-Lesezeichen über den Import-Button im Header. Viel Spaß!',
'onboarding.palette.title': 'Blitzschnell finden mit Strg+K',
'onboarding.palette.text': 'Drück jederzeit Strg+K, um die Befehls-Palette zu öffnen und alle Lesezeichen und Boards sofort zu durchsuchen. Mit ↑↓ navigieren, Enter öffnet, Esc schließt.',
// Notes
'notes.limit_message': 'Maximale Anzahl erreicht! Du kannst maximal {max} Notes gleichzeitig haben. Lösche eine bestehende Note um eine neue zu erstellen.',
@@ -385,9 +361,7 @@ const STRINGS = {
'settings.visible_count': 'Sichtbare Bookmarks',
'settings.visible_count.desc': 'Anzahl vor dem Ausblenden',
'settings.bg_url': 'Bild-URL',
'settings.bg_url.desc': 'Eigenes Bild per https-URL oder lokalem Upload',
'settings.bg_url.placeholder': 'https://… oder leer für Standard',
'settings.bg_url.privacy_hint': 'Hinweis: Ein per URL eingebundenes Bild wird bei jedem Öffnen vom fremden Server geladen.',
'settings.bg_url.desc': 'Eigenes Hintergrundbild per URL',
'settings.bg_change': 'Ändern',
'settings.bg_apply': 'Übernehmen',
'settings.bg_upload': 'Datei hochladen',
@@ -398,7 +372,7 @@ const STRINGS = {
'settings.onboarding_btn': 'Start',
'settings.reset_btn': 'Reset',
'settings.bg_upload_btn': 'Upload',
'settings.bg_invalid_url': 'Nur https-URLs oder lokale Bilder (Upload) sind als Hintergrund erlaubt.',
'settings.bg_invalid_url': 'Nur lokale Bilder (Upload) sind als Hintergrund erlaubt.',
'settings.bg_invalid_url.title': 'Ungültige URL',
// Modals
@@ -413,31 +387,6 @@ const STRINGS = {
'modal.rename_placeholder': 'Neuer Name...',
'modal.rename_confirm': 'Umbenennen',
'modal.theme_header': 'Darstellung',
// Theme-Picker-Cards (ARIA)
'theme.card.nebula': 'Theme Nebula wählen',
'theme.card.crescent': 'Theme Crescent wählen',
'theme.card.event_horizon': 'Theme Event Horizon wählen',
'theme.card.merchantman': 'Theme Merchantman wählen',
'theme.card.julia_jin': 'Theme Julia & Jin wählen',
'theme.card.sc_sunset': 'Theme SC Sunset wählen',
'theme.card.hellion_hud': 'Theme Hellion HUD wählen',
'theme.card.hellion_energy': 'Theme Hellion Energy wählen',
'theme.card.satisfactory': 'Theme Satisfactory wählen',
'theme.card.avorion': 'Theme Avorion wählen',
'theme.card.hellion_stealth': 'Theme Hellion Stealth wählen',
'theme.card.custom': 'Eigenes Theme wählen',
'theme.builder.title': 'Eigenes',
'theme.builder.accent': 'Akzent',
'theme.builder.bg': 'Hintergrund',
'theme.builder.board': 'Board-Fläche',
'theme.builder.text': 'Text primär',
'theme.builder.text_secondary': 'Text sekundär',
'theme.builder.text_muted': 'Text gedämpft',
'theme.builder.reset': 'Zurücksetzen',
'theme.builder.contrast_good': 'Gut lesbar',
'theme.builder.contrast_ok': 'Grenzwertig',
'theme.builder.contrast_bad': 'Schwer lesbar',
'toolbar.label': 'Widget-Werkzeugleiste',
// About
'about.title': '⬡ HELLION NEWTAB',
@@ -464,19 +413,7 @@ const STRINGS = {
'toolbar.calculator': 'Taschenrechner',
'toolbar.timer': 'Timer',
'toolbar.imageref': 'Bild-Referenz',
'toolbar.notebook': 'Alle Notes',
// Command-Palette
'palette.placeholder': 'Bookmarks & Boards durchsuchen…',
'palette.aria_label': 'Bookmark-Suche',
'palette.list_label': 'Suchergebnisse',
'palette.no_results': 'Keine Treffer',
'palette.hint': 'Strg+K zum Öffnen · ↑↓ zum Navigieren · Enter öffnet · Esc schließt',
'palette.count': '{count} Treffer',
'palette.count_one': '1 Treffer',
'palette.board_prefix': 'Board:',
'palette.discover': 'Lesezeichen & Boards durchsuchen (Strg+K)',
'palette.discover_label': 'Suchen'
'toolbar.notebook': 'Alle Notes'
},
en: {
@@ -496,8 +433,6 @@ const STRINGS = {
'boards.drag_title': 'Move board',
'boards.blur': 'Blur (private)',
'boards.unblur': 'Unblur',
'boards.lock': 'Lock position',
'boards.unlock': 'Unlock position',
'boards.rename': 'Rename',
'boards.delete': 'Delete',
'boards.delete_confirm': 'Really delete board "{title}"?',
@@ -507,26 +442,6 @@ const STRINGS = {
'boards.add_link': ' Add link',
'boards.remove_bookmark': 'Remove',
// Trash
'trash.section': 'TRASH',
'trash.empty': 'The trash is empty.',
'trash.deleted_at': 'Deleted on {date}',
'trash.type.bookmark': 'Bookmark',
'trash.type.board': 'Board',
'trash.from_board': 'from "{board}"',
'trash.from_board_unknown': 'Origin board deleted',
'trash.restore': 'Restore',
'trash.restore_title': 'Restore',
'trash.delete_forever': 'Delete forever',
'trash.delete_forever_title': 'Delete forever',
'trash.empty_btn': 'Empty trash',
'trash.empty_confirm': 'Permanently delete all {count} entries? This cannot be undone.',
'trash.empty_confirm.title': 'Empty trash',
'trash.delete_forever_confirm': 'Permanently delete this entry? This cannot be undone.',
'trash.delete_forever_confirm.title': 'Delete forever',
'trash.restored_to_inbox': 'Bookmark restored to the inbox (origin board no longer exists).',
'trash.restored_to_inbox.title': 'Restored',
// Onboarding
'onboarding.skip': 'Skip',
'onboarding.back': 'Back',
@@ -541,8 +456,8 @@ const STRINGS = {
'onboarding.s2.f2': 'Import browser bookmarks via the "Import" button in the header',
'onboarding.s2.f3': 'Drag & drop to reorder boards and links',
'onboarding.s2.f4': 'Blur mode for private boards (🔒 icon)',
'onboarding.s3.title': 'Hand-crafted themes + your own',
'onboarding.s3.text': 'Click the "Theme" button in the header to choose a theme or build your own. Each has its own style.',
'onboarding.s3.title': '11 handcrafted themes',
'onboarding.s3.text': 'Click the "Theme" button in the header to choose your theme. Each has its own style and color palette.',
'onboarding.s4.title': 'Widget Toolbar',
'onboarding.s4.f1': 'The floating buttons on the right open widgets',
'onboarding.s4.f2': 'Notes and checklists for quick notes',
@@ -557,8 +472,6 @@ const STRINGS = {
'onboarding.tradecenter_desc': 'Trade Center for Star Citizen',
'onboarding.s7.title': 'Ready!',
'onboarding.s7.text': 'Create your first board with "+ Board" or import your browser bookmarks via the Import button in the header. Have fun!',
'onboarding.palette.title': 'Find anything with Ctrl+K',
'onboarding.palette.text': 'Press Ctrl+K anytime to open the command palette and instantly search all your bookmarks and boards. Navigate with ↑↓, Enter opens, Esc closes.',
// Notes
'notes.limit_message': 'Maximum reached! You can have at most {max} notes at the same time. Delete an existing note to create a new one.',
@@ -860,9 +773,7 @@ const STRINGS = {
'settings.visible_count': 'Visible bookmarks',
'settings.visible_count.desc': 'Number before hiding',
'settings.bg_url': 'Image URL',
'settings.bg_url.desc': 'Custom image via https URL or local upload',
'settings.bg_url.placeholder': 'https://… or empty for default',
'settings.bg_url.privacy_hint': 'Note: an image loaded via URL is fetched from the remote server every time you open a tab.',
'settings.bg_url.desc': 'Custom background image via URL',
'settings.bg_change': 'Change',
'settings.bg_apply': 'Apply',
'settings.bg_upload': 'Upload file',
@@ -873,7 +784,7 @@ const STRINGS = {
'settings.onboarding_btn': 'Start',
'settings.reset_btn': 'Reset',
'settings.bg_upload_btn': 'Upload',
'settings.bg_invalid_url': 'Only https URLs or local images (upload) are allowed as background.',
'settings.bg_invalid_url': 'Only local images (upload) are allowed as background.',
'settings.bg_invalid_url.title': 'Invalid URL',
// Modals
@@ -888,31 +799,6 @@ const STRINGS = {
'modal.rename_placeholder': 'New name...',
'modal.rename_confirm': 'Rename',
'modal.theme_header': 'Theme',
// Theme picker cards (ARIA)
'theme.card.nebula': 'Select Nebula theme',
'theme.card.crescent': 'Select Crescent theme',
'theme.card.event_horizon': 'Select Event Horizon theme',
'theme.card.merchantman': 'Select Merchantman theme',
'theme.card.julia_jin': 'Select Julia & Jin theme',
'theme.card.sc_sunset': 'Select SC Sunset theme',
'theme.card.hellion_hud': 'Select Hellion HUD theme',
'theme.card.hellion_energy': 'Select Hellion Energy theme',
'theme.card.satisfactory': 'Select Satisfactory theme',
'theme.card.avorion': 'Select Avorion theme',
'theme.card.hellion_stealth': 'Select Hellion Stealth theme',
'theme.card.custom': 'Select custom theme',
'theme.builder.title': 'Custom',
'theme.builder.accent': 'Accent',
'theme.builder.bg': 'Background',
'theme.builder.board': 'Board surface',
'theme.builder.text': 'Text primary',
'theme.builder.text_secondary': 'Text secondary',
'theme.builder.text_muted': 'Text muted',
'theme.builder.reset': 'Reset',
'theme.builder.contrast_good': 'Good contrast',
'theme.builder.contrast_ok': 'Borderline',
'theme.builder.contrast_bad': 'Hard to read',
'toolbar.label': 'Widget toolbar',
// About
'about.title': '⬡ HELLION NEWTAB',
@@ -939,19 +825,7 @@ const STRINGS = {
'toolbar.calculator': 'Calculator',
'toolbar.timer': 'Timer',
'toolbar.imageref': 'Image reference',
'toolbar.notebook': 'All notes',
// Command palette
'palette.placeholder': 'Search bookmarks & boards…',
'palette.aria_label': 'Bookmark search',
'palette.list_label': 'Search results',
'palette.no_results': 'No results',
'palette.hint': 'Ctrl+K to open · ↑↓ to navigate · Enter opens · Esc closes',
'palette.count': '{count} results',
'palette.count_one': '1 result',
'palette.board_prefix': 'Board:',
'palette.discover': 'Search bookmarks & boards (Ctrl+K)',
'palette.discover_label': 'Search'
'toolbar.notebook': 'All notes'
}
};
@@ -991,10 +865,6 @@ function applyLanguage() {
el.title = text;
el.setAttribute('aria-label', text);
});
// aria-label ohne sichtbaren title (z.B. Theme-Cards)
document.querySelectorAll('[data-i18n-aria-label]').forEach(el => {
el.setAttribute('aria-label', t(el.dataset.i18nAriaLabel));
});
}
/**
-5
View File
@@ -39,11 +39,6 @@ const Onboarding = {
textKey: 'onboarding.s6.text',
interactive: 'gaming-board'
},
{
hero: '\uD83D\uDD0D',
titleKey: 'onboarding.palette.title',
textKey: 'onboarding.palette.text'
},
{
hero: '\uD83D\uDE80',
titleKey: 'onboarding.s7.title',
-56
View File
@@ -32,59 +32,3 @@ chrome.tabs.onActivated.addListener((activeInfo) => {
if (tab) forceRedirect(tab.id, tab.url);
});
});
// ---- QUICK SAVE (v2.3, additiv — Redirect oben unberuehrt) ----
// Geteiltes Helfer-Modul: ensureInbox(boards), uid(), normalizeBookmark(...).
// Pfad ist relativ zu DIESER Datei (src/js/opera/), daher ../quicksave-core.js.
importScripts('../quicksave-core.js');
// Interne/nicht speicherbare Seiten (Browser-UI, Extension-Seiten) — kein sinnvolles Bookmark.
const UNSAVEABLE_URL = /^(chrome|chrome-extension|about|edge|opera|moz-extension|brave|vivaldi|view-source|devtools):/i;
// Re-Entry-Schutz: ein zweiter Quick-Save waehrend der erste laeuft wuerde read-modify-write
// rennen (lost update). Bei aktivem Save den zweiten Druck verwerfen.
let qsBusy = false;
function quickSaveActiveTab() {
if (qsBusy) return;
qsBusy = true;
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const tab = tabs && tabs[0];
if (!tab || !tab.url || UNSAVEABLE_URL.test(tab.url)) {
// Kein speicherbarer Tab: kurzer roter Marker (langer Text wird im Badge abgeschnitten).
chrome.action.setBadgeText({ text: '×' });
if (chrome.action.setBadgeBackgroundColor) {
chrome.action.setBadgeBackgroundColor({ color: '#c0392b' });
}
setTimeout(() => chrome.action.setBadgeText({ text: '' }), 2000);
qsBusy = false;
return;
}
// Datensicher: NICHT boards schreiben — nur an die Pending-Queue anhaengen (die Seite
// drained sie in die Inbox). So kann der Worker das boards-Array nicht clobbern (Review 2b).
chrome.storage.local.get(['quicksave_pending'], (r) => {
const pending = Array.isArray(r.quicksave_pending) ? r.quicksave_pending : [];
pending.push({ id: uid(), title: tab.title || tab.url, url: tab.url });
chrome.storage.local.set({ quicksave_pending: pending }, () => {
if (chrome.runtime.lastError) {
console.error('Quick-Save fehlgeschlagen:', chrome.runtime.lastError.message);
qsBusy = false;
return;
}
chrome.action.setBadgeText({ text: chrome.i18n.getMessage('quickSaveBadge') });
if (chrome.action.setBadgeBackgroundColor) {
chrome.action.setBadgeBackgroundColor({ color: '#1f9d55' });
}
setTimeout(() => chrome.action.setBadgeText({ text: '' }), 2000);
qsBusy = false;
});
});
});
}
// onCommand SYNCHRON auf Top-Level (additiv neben den Redirect-Listenern).
chrome.commands.onCommand.addListener((command) => {
if (command === 'quick-save') {
quickSaveActiveTab();
}
});
-240
View File
@@ -1,240 +0,0 @@
/* =============================================
HELLION NEWTAB — palette.js
Command-Palette (Strg+K): read-only Suche ueber Boards/Bookmarks
============================================= */
// Reine init-Funktion ohne Top-Level-Seiteneffekt (Hausmuster wie initSearch).
// Wird aus app.js init() nach initSearch() aufgerufen.
function initPalette() {
let overlay = null; // aktives Overlay-Element oder null (= geschlossen)
let prevFocus = null; // Fokus-Rueckgabeziel
let keyHandler = null; // dokument-weiter Handler im offenen Zustand
let results = []; // aktuelle Trefferliste [{ title, url, boardName }]
let activeIndex = -1; // aktiver Listeneintrag fuer aria-activedescendant
// ---- Open-Guard: kein Strg+K wenn ein anderes Overlay offen ist ----
// Deckt Settings (.panel-overlay), Theme/Add-Board/Add-Bookmark/Rename
// (.modal-overlay) sowie HellionDialog UND Onboarding (.dialog-overlay) ab.
function isBlocked() {
// .palette-overlay ausklammern: das eigene (beim Schliessen noch deferred .active
// tragende) Overlay darf den Reopen-Guard nicht selbst blockieren (Self-Block-Race
// beim Toggle-Spam, da close() .active erst in withViewTransition entfernt).
return !!document.querySelector(
'.panel-overlay.active, .modal-overlay.active, .dialog-overlay.active:not(.palette-overlay)'
);
}
// ---- Trefferquelle: flach ueber alle Boards/Bookmarks ----
// Read-only auf dem globalen boards-Array. Match auf Titel, URL, Board-Name.
function search(query) {
const q = query.trim().toLowerCase();
if (!q) return [];
const out = [];
for (const board of boards) {
const boardName = board.title || '';
const boardMatch = boardName.toLowerCase().includes(q);
for (const bm of (board.bookmarks || [])) {
const title = bm.title || '';
const url = bm.url || '';
if (
title.toLowerCase().includes(q) ||
url.toLowerCase().includes(q) ||
boardMatch
) {
out.push({ title, url, boardName });
}
}
}
return out;
}
// ---- Treffer oeffnen (wie boards.js:270) ----
function openResult(item) {
if (!item || !item.url) return;
// Sicherheit: nur sichere Protokolle oeffnen. Verhindert javascript:/data:-URLs aus
// importierten Bookmarks (XSS im Extension-Origin, besonders bei _self). http/https/ftp only.
let safe = false;
try { safe = ['http:', 'https:', 'ftp:'].includes(new URL(item.url).protocol); } catch (e) { /* ungueltige URL */ }
if (!safe) { close(); return; }
window.open(item.url, settings.newTab ? '_blank' : '_self', 'noopener,noreferrer');
close();
}
// ---- Listbox neu rendern ----
function renderList(listEl, liveEl) {
listEl.textContent = '';
activeIndex = results.length ? 0 : -1;
if (results.length === 0) {
// Kein role="option": der Leerzustand ist keine auswaehlbare Option, sondern eine
// Statuszeile. Die Ansage uebernimmt die aria-live-Region (liveEl) unten.
const empty = document.createElement('li');
empty.className = 'palette-empty';
empty.setAttribute('role', 'presentation');
empty.textContent = t('palette.no_results');
listEl.appendChild(empty);
liveEl.textContent = t('palette.no_results');
return;
}
results.forEach((item, i) => {
const li = document.createElement('li');
li.className = 'palette-option';
li.id = 'palette-opt-' + i;
li.setAttribute('role', 'option');
li.setAttribute('aria-selected', i === 0 ? 'true' : 'false');
const titleSpan = document.createElement('span');
titleSpan.className = 'palette-option-title';
titleSpan.textContent = item.title;
const metaSpan = document.createElement('span');
metaSpan.className = 'palette-option-meta';
metaSpan.textContent = t('palette.board_prefix') + ' ' + item.boardName;
li.append(titleSpan, metaSpan);
// Pointer-Auswahl: Klick oeffnet, Hover markiert
li.addEventListener('click', () => openResult(item));
li.addEventListener('mousemove', () => setActive(listEl, i));
listEl.appendChild(li);
});
const count = results.length;
liveEl.textContent = count === 1 ? t('palette.count_one') : t('palette.count', { count });
}
// ---- aktiven Eintrag setzen (aria-activedescendant + aria-selected) ----
function setActive(listEl, idx) {
// Guard: close() nullt overlay synchron, das DOM-Removal laeuft aber deferred in
// withViewTransition. In diesem Frame-Fenster kann ein mousemove auf einem noch
// lebenden Treffer-<li> setActive() ausloesen -> ohne diesen Guard Null-Deref auf overlay.
if (!overlay) return;
const options = listEl.querySelectorAll('.palette-option');
if (options.length === 0) return;
activeIndex = Math.max(0, Math.min(idx, options.length - 1));
options.forEach((opt, i) => {
opt.setAttribute('aria-selected', i === activeIndex ? 'true' : 'false');
});
const input = overlay.querySelector('.palette-input');
input.setAttribute('aria-activedescendant', options[activeIndex].id);
options[activeIndex].scrollIntoView({ block: 'nearest' });
}
// ---- schliessen: dialog.js-Cleanup-Muster (remove Listener -> Transition -> Fokus) ----
function close() {
if (!overlay) return;
document.removeEventListener('keydown', keyHandler);
const el = overlay;
overlay = null;
keyHandler = null;
withViewTransition(() => {
el.classList.remove('active');
el.remove();
});
if (prevFocus && typeof prevFocus.focus === 'function') prevFocus.focus();
prevFocus = null;
}
// ---- oeffnen: Overlay nach dialog.js-Muster aufbauen ----
function open() {
if (overlay) return;
prevFocus = document.activeElement;
overlay = document.createElement('div');
overlay.className = 'dialog-overlay palette-overlay';
const box = document.createElement('div');
box.className = 'palette-box';
box.setAttribute('role', 'none');
// ARIA-1.2-Combobox: role=combobox gehoert auf das fokussierbare Textfeld selbst,
// nicht auf die Huelle. aria-expanded/haspopup/controls/activedescendant ebenfalls hier.
const input = document.createElement('input');
input.className = 'palette-input';
input.type = 'text';
input.setAttribute('role', 'combobox');
input.setAttribute('aria-expanded', 'true');
input.setAttribute('aria-haspopup', 'listbox');
input.setAttribute('aria-label', t('palette.aria_label'));
input.setAttribute('aria-autocomplete', 'list');
input.setAttribute('aria-controls', 'palette-listbox');
input.placeholder = t('palette.placeholder');
input.autocomplete = 'off';
input.spellcheck = false;
const list = document.createElement('ul');
list.className = 'palette-list';
list.id = 'palette-listbox';
list.setAttribute('role', 'listbox');
list.setAttribute('aria-label', t('palette.list_label'));
const live = document.createElement('div');
live.className = 'palette-live';
live.setAttribute('aria-live', 'polite');
const hint = document.createElement('div');
hint.className = 'palette-hint';
hint.textContent = t('palette.hint');
box.append(input, list, live, hint);
overlay.appendChild(box);
// Klick auf den Overlay-Hintergrund schliesst (wie dialog.js:107)
overlay.addEventListener('click', e => {
if (e.target === overlay) close();
});
// Live-Filter
input.addEventListener('input', () => {
results = search(input.value);
renderList(list, live);
input.setAttribute('aria-activedescendant', activeIndex >= 0 ? 'palette-opt-' + activeIndex : '');
});
// Tastatursteuerung: Pfeile/Enter/Escape + Fokus-Falle (nur input fokussierbar)
keyHandler = function (e) {
if (e.key === 'Escape') { e.preventDefault(); close(); return; }
if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0 && results[activeIndex]) openResult(results[activeIndex]);
return;
}
if (e.key === 'ArrowDown') { e.preventDefault(); setActive(list, activeIndex + 1); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); setActive(list, activeIndex - 1); return; }
if (e.key === 'Tab') {
// Fokus-Falle ueber den Container: das Eingabefeld ist das einzige
// fokussierbare Element, also Tab/Shift+Tab immer dorthin zurueck.
e.preventDefault();
input.focus();
}
};
document.addEventListener('keydown', keyHandler);
document.body.appendChild(overlay);
// View-Transition uebernimmt das Fade; Fokus ins Eingabefeld
withViewTransition(() => {
overlay.classList.add('active');
input.focus();
});
}
// ---- globaler Ausloeser Strg+K (Meta+K auf Mac) mit Open-Guard ----
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
if (overlay) { e.preventDefault(); close(); return; }
if (isBlocked()) return;
e.preventDefault();
open();
}
});
// Persistenter Header-Trigger (BS-08): Klick toggelt die Palette wie Strg+K.
const paletteBtn = document.getElementById('btnPalette');
if (paletteBtn) {
paletteBtn.addEventListener('click', () => {
if (overlay) { close(); return; }
if (isBlocked()) return;
open();
});
}
}
-69
View File
@@ -1,69 +0,0 @@
/* =============================================
HELLION NEWTAB — quicksave-core.js
DOM-freie geteilte Helfer fuer Seite UND Background-Worker.
Laeuft als <script> (newtab.html) und via importScripts (Service-Worker/Event-Page).
KEIN window/document/Store-Zugriff. Alle Exporte auf globalThis.
============================================= */
(function (root) {
'use strict';
// Feste, nicht-zufaellige ID des Inbox-Boards (Quick-Save-Ziel).
// Bewusst KEIN uid(): die Inbox muss von Seite und Worker deterministisch
// wiedererkennbar sein, sonst entstehen Duplikate (QS-08).
const INBOX_ID = 'inbox';
// Kollisionsarme Kurz-ID. Identisch zur frueheren state.js-Variante,
// damit bestehende Aufrufer (boards.js, data.js, app.js, bookmark-import.js) unveraendert weiterlaufen.
function uid() {
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
}
// Sichert, dass im uebergebenen boards-Array genau EIN Inbox-Board existiert,
// und gibt dieses Inbox-Board-Objekt zurueck. Idempotent: findet ein Board mit
// id === INBOX_ID, sonst legt es eins mit fester id vorne an (unshift) und mutiert
// das uebergebene Array dabei IN-PLACE. Der Worker schreibt anschliessend dasselbe
// (mutierte) boards-Array via storage zurueck; der Rueckgabewert ist das Board,
// damit Aufrufer direkt inbox.bookmarks.push(...) machen koennen (QS-08).
function ensureInbox(boardsArr) {
const list = Array.isArray(boardsArr) ? boardsArr : [];
let inbox = list.find(b => b && b.id === INBOX_ID);
if (!inbox) {
inbox = { id: INBOX_ID, title: 'Inbox', bookmarks: [], blurred: false };
list.unshift(inbox);
}
return inbox;
}
// Sicheres URL-Protokoll (http/https/ftp). Inhaltlich identisch zur data.js-Variante, aber
// DOM-frei und auf globalThis, damit der Quick-Save-Drain (app.js) dieselbe Validierung nutzt
// wie jeder andere Bookmark-Schreibpfad. URL ist in Worker UND Seite verfuegbar.
function isSafeUrl(url) {
try {
return ['http:', 'https:', 'ftp:'].includes(new URL(url).protocol);
} catch (_) {
return false;
}
}
// Normalisiert eine Bookmark in die kanonische Form { id, title, url, desc }.
// title-Fallback auf url, desc auf ''. Begrenzt Laengen wie data.js (200/500),
// damit Quick-Save-Eintraege das gleiche Schema wie Import/Manuell haben.
function normalizeBookmark(raw) {
const url = (raw && typeof raw.url === 'string') ? raw.url : '';
const title = (raw && typeof raw.title === 'string' && raw.title.trim())
? raw.title.trim()
: url;
return {
id: (raw && raw.id) ? raw.id : uid(),
title: String(title).slice(0, 200),
url: url,
desc: String((raw && raw.desc) || '').slice(0, 500)
};
}
root.INBOX_ID = INBOX_ID;
root.uid = uid;
root.isSafeUrl = isSafeUrl;
root.ensureInbox = ensureInbox;
root.normalizeBookmark = normalizeBookmark;
})(typeof globalThis !== 'undefined' ? globalThis : self);
+12 -454
View File
@@ -3,108 +3,24 @@
Settings Panel, Theme-Modal, Accordion, Toggles
============================================= */
// ---- A11Y: Fokus-Management fuer Modals ----
// Merkt sich das vor dem Oeffnen fokussierte Element, damit wir es beim
// Schliessen restaurieren koennen. Pro offenem Modal eine Closure-Variable.
const _focusReturn = { settings: null, theme: null };
/** Liefert die fokussierbaren Elemente innerhalb eines Containers. */
function _focusable(container) {
return Array.from(container.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
)).filter(el => el.offsetParent !== null);
}
/** Tab/Shift+Tab im Container einfangen + Escape schliesst. */
function _makeTrap(container, closeFn) {
return function trap(e) {
// Ein offener HellionDialog (z.B. Reset-All-Confirm oder BG-URL-Alert aus
// dem Panel) hat Vorrang: sein eigener keydown-Handler uebernimmt Escape/Tab.
// Sonst schloessen beide Listener gleichzeitig und die Dialog-Fokusfalle wird loechrig.
if (document.querySelector('.dialog-overlay')) return;
if (e.key === 'Escape') { e.preventDefault(); closeFn(); return; }
if (e.key !== 'Tab') return;
const items = _focusable(container);
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault(); last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault(); first.focus();
}
};
}
// ---- SETTINGS PANEL ----
// Hinweis: withViewTransition (Phase 4) bleibt fuer das Fade erhalten; das
// Fokus-Management (merken, Falle, Rueckgabe) liegt bewusst ausserhalb des
// Transition-Callbacks. activeElement wird vor der Mutation gelesen.
let _settingsTrap = null;
function openSettings() {
const panel = document.getElementById('settingsPanel');
_focusReturn.settings = document.activeElement;
withViewTransition(() => {
panel.classList.add('open');
document.getElementById('settingsPanel').classList.add('open');
document.getElementById('settingsOverlay').classList.add('active');
});
panel.setAttribute('aria-hidden', 'false');
renderTrash();
_settingsTrap = _makeTrap(panel, closeSettings);
document.addEventListener('keydown', _settingsTrap);
const first = _focusable(panel)[0];
if (first) first.focus();
}
function closeSettings() {
const panel = document.getElementById('settingsPanel');
withViewTransition(() => {
panel.classList.remove('open');
document.getElementById('settingsPanel').classList.remove('open');
document.getElementById('settingsOverlay').classList.remove('active');
});
panel.setAttribute('aria-hidden', 'true');
if (_settingsTrap) { document.removeEventListener('keydown', _settingsTrap); _settingsTrap = null; }
if (_focusReturn.settings) { _focusReturn.settings.focus(); _focusReturn.settings = null; }
}
// ---- THEME MODAL ----
let _themeTrap = null;
function openThemeModal() {
const overlay = document.getElementById('themeOverlay');
const modal = document.getElementById('themeModal');
_focusReturn.theme = document.activeElement;
withViewTransition(() => {
overlay.classList.add('active');
});
modal.setAttribute('aria-hidden', 'false');
_themeTrap = _makeTrap(modal, closeThemeModal);
document.addEventListener('keydown', _themeTrap);
const first = _focusable(modal)[0];
if (first) first.focus();
syncCustomPickers();
document.getElementById('themeBuilderPanel').classList.toggle('hidden', settings.theme !== 'custom');
}
function closeThemeModal() {
const overlay = document.getElementById('themeOverlay');
const modal = document.getElementById('themeModal');
withViewTransition(() => {
overlay.classList.remove('active');
});
modal.setAttribute('aria-hidden', 'true');
if (_themeTrap) { document.removeEventListener('keydown', _themeTrap); _themeTrap = null; }
if (_focusReturn.theme) { _focusReturn.theme.focus(); _focusReturn.theme = null; }
}
/**
* Wechselt das Theme mit nativem Cross-Fade (View Transitions API).
* Wrap sitzt bewusst hier am User-Ausloeser, NICHT in applyTheme(),
* sonst fadet jeder neue Tab beim Initial-Load (settings.js:101).
* Feature-Detection-Fallback: aeltere Browser (z.B. Firefox < 144)
* schalten instant um, ohne Bruch.
* @param {string} name - Theme-Name
*/
function switchTheme(name) {
const swap = () => applyTheme(name, false); // false: Theme-BG anwenden (kein User-bgUrl-Schutz hier noetig, bgUrl wurde geleert)
withViewTransition(swap);
}
/**
@@ -115,131 +31,7 @@ function switchTheme(name) {
*/
function isValidBgUrl(url) {
return typeof url === 'string' && url.length > 0 &&
(url.startsWith('blob:') || url.startsWith('data:image/') || url.startsWith('https://'));
}
// ---- THEME-BUILDER: Konstanten + reine Helfer ----
const CUSTOM_DEFAULTS = {
accent: '#6c8cff', bgPrimary: '#0b0d12', bgBoard: '#141821',
textPrimary: '#e6e8ef', textSecondary: '#9aa3b8', textMuted: '#5b6478',
};
const HEX_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
function isValidHexColor(v) { return typeof v === 'string' && HEX_RE.test(v); }
function safeHex(v, fallback) { return isValidHexColor(v) ? v : fallback; }
function hexToRgba(hex, alpha) {
let h = hex.replace('#', '');
if (h.length === 3) h = h.split('').map(c => c + c).join('');
const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
// WCAG 2.x Relativluminanz + Kontrastverhaeltnis
function relLuminance(hex) {
let h = hex.replace('#', '');
if (h.length === 3) h = h.split('').map(c => c + c).join('');
const lin = [0, 2, 4].map(i => {
const c = parseInt(h.slice(i, i + 2), 16) / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
}
function contrastRatio(hexA, hexB) {
const a = relLuminance(hexA), b = relLuminance(hexB);
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}
function updateContrastIndicator(textHex, bgHex) {
const el = document.getElementById('tbContrast');
if (!el) return;
const ratio = contrastRatio(textHex, bgHex);
let cls, key;
if (ratio >= 4.5) { cls = 'good'; key = 'theme.builder.contrast_good'; }
else if (ratio >= 3) { cls = 'ok'; key = 'theme.builder.contrast_ok'; }
else { cls = 'bad'; key = 'theme.builder.contrast_bad'; }
el.classList.remove('good', 'ok', 'bad');
el.classList.add(cls);
const txt = document.getElementById('tbContrastText');
if (txt) txt.textContent = `${t(key)} (${ratio.toFixed(1)}:1)`;
}
// Setzt data-theme='custom' + 6 validierte Inline-Vars (Gate vor jedem setProperty).
function applyCustomTheme(ct) {
const root = document.documentElement;
const c = ct || {};
const accent = safeHex(c.accent, CUSTOM_DEFAULTS.accent);
const bgPrimary = safeHex(c.bgPrimary, CUSTOM_DEFAULTS.bgPrimary);
const bgBoard = safeHex(c.bgBoard, CUSTOM_DEFAULTS.bgBoard);
const textPrimary = safeHex(c.textPrimary, CUSTOM_DEFAULTS.textPrimary);
const textSecondary = safeHex(c.textSecondary, CUSTOM_DEFAULTS.textSecondary);
const textMuted = safeHex(c.textMuted, CUSTOM_DEFAULTS.textMuted);
root.setAttribute('data-theme', 'custom');
root.style.setProperty('--accent', accent);
root.style.setProperty('--bg-primary', bgPrimary);
root.style.setProperty('--bg-board', hexToRgba(bgBoard, 0.55));
root.style.setProperty('--text-primary', textPrimary);
root.style.setProperty('--text-secondary', textSecondary);
root.style.setProperty('--text-muted', textMuted);
document.querySelectorAll('.theme-card').forEach(card => {
const on = card.dataset.value === 'custom';
card.classList.toggle('active', on);
card.setAttribute('aria-pressed', on ? 'true' : 'false');
});
updateContrastIndicator(textPrimary, bgPrimary);
// Kein eigenes Bild gesetzt -> bgLayer leeren, damit --bg-primary (Solid) durchscheint
// statt des Hintergrundbilds eines zuvor gewaehlten Presets (das sonst haengen bliebe).
if (!(settings.bgUrl && isValidBgUrl(settings.bgUrl))) {
document.getElementById('bgLayer').style.backgroundImage = '';
}
}
// Entfernt die 6 Inline-Vars (Rueckwechsel auf Preset / Reset).
function clearCustomTheme() {
const root = document.documentElement;
['--accent', '--bg-primary', '--bg-board', '--text-primary', '--text-secondary', '--text-muted']
.forEach(v => root.style.removeProperty(v));
}
// Schreibt die gespeicherten (oder Default-) Farben in die 6 Picker-Inputs.
function syncCustomPickers() {
const ct = settings.customTheme || {};
const set = (id, key) => { const el = document.getElementById(id); if (el) el.value = safeHex(ct[key], CUSTOM_DEFAULTS[key]); };
set('tbAccent', 'accent'); set('tbBg', 'bgPrimary'); set('tbBoard', 'bgBoard');
set('tbText', 'textPrimary'); set('tbTextSec', 'textSecondary'); set('tbTextMuted', 'textMuted');
}
// Eigenes Upload-Bild Quota-schonend verkleinern: auf die laengste Bildschirmkante
// (× devicePixelRatio, gedeckelt) herunterrechnen und als WebP neu kodieren. Das spart
// gegenueber dem rohen Base64-Upload locker den Grossteil der chrome.storage.local-Quota.
// Greift nur beim lokalen Upload (data:-URL ist same-origin, Canvas wird nicht getainted);
// https-Hintergruende liegen remote und kosten keine Quota.
function downscaleBgImage(dataUrl) {
const MAX_DIM = Math.min(2560, Math.round(Math.max(screen.width, screen.height) * (window.devicePixelRatio || 1)));
const QUALITY = 0.82;
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const scale = Math.min(1, MAX_DIM / Math.max(img.naturalWidth, img.naturalHeight));
const w = Math.max(1, Math.round(img.naturalWidth * scale));
const h = Math.max(1, Math.round(img.naturalHeight * scale));
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) { resolve(dataUrl); return; } // kein 2D-Context -> Original behalten
ctx.drawImage(img, 0, 0, w, h);
// WebP wo verfuegbar (Chrome/Opera/FF142+); sonst faellt toDataURL auf PNG zurueck -> dann JPEG
let out = canvas.toDataURL('image/webp', QUALITY);
if (!out.startsWith('data:image/webp')) out = canvas.toDataURL('image/jpeg', QUALITY);
resolve(out);
};
img.onerror = () => reject(new Error('image decode failed'));
img.src = dataUrl;
});
(url.startsWith('blob:') || url.startsWith('data:image/'));
}
// ---- ACCORDION ----
@@ -268,165 +60,6 @@ function initAccordion() {
});
}
// ---- PAPIERKORB ----
/**
* Formatiert einen deletedAt-Timestamp lokalisiert (folgt der aktiven UI-Sprache).
* @param {number} ts - Millisekunden-Timestamp
* @returns {string}
*/
function formatTrashDate(ts) {
const locale = I18n.currentLang === 'de' ? 'de-DE' : 'en-US';
return new Date(ts).toLocaleDateString(locale, { year: 'numeric', month: '2-digit', day: '2-digit' });
}
/**
* Rendert den Papierkorb in die Settings-Section. Wird bei jedem openSettings()
* sowie nach jeder Trash-Mutation aufgerufen. Baut DOM ohne innerHTML (XSS-frei,
* Titel kommen aus User-/Importdaten).
*/
function renderTrash() {
const listEl = document.getElementById('trashList');
const actionsRow = document.getElementById('trashActionsRow');
if (!listEl) return;
listEl.replaceChildren();
if (trash.length === 0) {
const empty = document.createElement('div');
empty.className = 'trash-empty';
empty.textContent = t('trash.empty');
listEl.appendChild(empty);
if (actionsRow) actionsRow.classList.add('hidden');
return;
}
if (actionsRow) actionsRow.classList.remove('hidden');
// Neueste zuerst.
const sorted = [...trash].sort((a, b) => b.deletedAt - a.deletedAt);
sorted.forEach(entry => listEl.appendChild(createTrashItemEl(entry)));
}
/**
* Baut eine einzelne Papierkorb-Zeile.
* @param {Object} entry - trash-Eintrag { item, type, originBoardId, deletedAt }
* @returns {HTMLElement}
*/
function createTrashItemEl(entry) {
const row = document.createElement('div');
row.className = 'trash-item';
const info = document.createElement('div');
info.className = 'trash-item-info';
const titleLine = document.createElement('span');
titleLine.className = 'trash-item-title';
const badge = document.createElement('span');
badge.className = 'trash-item-badge';
badge.textContent = entry.type === 'board' ? t('trash.type.board') : t('trash.type.bookmark');
const titleText = document.createTextNode(entry.item && entry.item.title ? entry.item.title : '');
titleLine.append(badge, titleText);
const meta = document.createElement('span');
meta.className = 'trash-item-meta';
let metaText = t('trash.deleted_at', { date: formatTrashDate(entry.deletedAt) });
if (entry.type === 'bookmark') {
const origin = entry.originBoardId ? boards.find(b => b.id === entry.originBoardId) : null;
metaText += origin
? ' · ' + t('trash.from_board', { board: origin.title })
: ' · ' + t('trash.from_board_unknown');
}
meta.textContent = metaText;
info.append(titleLine, meta);
const actions = document.createElement('div');
actions.className = 'trash-item-actions';
const btnRestore = document.createElement('button');
btnRestore.className = 'btn-small';
btnRestore.textContent = t('trash.restore');
btnRestore.title = t('trash.restore_title');
btnRestore.addEventListener('click', () => { btnRestore.disabled = true; restoreTrashEntry(entry); });
const btnForever = document.createElement('button');
btnForever.className = 'btn-danger';
btnForever.textContent = t('trash.delete_forever');
btnForever.title = t('trash.delete_forever_title');
btnForever.addEventListener('click', () => deleteTrashEntryForever(entry));
actions.append(btnRestore, btnForever);
row.append(info, actions);
return row;
}
/**
* Stellt einen Papierkorb-Eintrag wieder her.
* Bookmark -> in originBoardId (falls noch vorhanden), sonst in die Inbox (ensureInboxBoard).
* Board -> zurueck in boards[].
* @param {Object} entry
*/
async function restoreTrashEntry(entry) {
// Re-Entry-Guard: ein zweiter Klick (z.B. waehrend der Inbox-Alert offen ist) wuerde sonst
// das Item ein zweites Mal einfuegen (Duplikat). Nach der ersten Ausfuehrung ist entry
// nicht mehr in trash[]; btnRestore wird zusaetzlich beim ersten Klick disabled.
if (!trash.includes(entry)) return;
if (entry.type === 'board') {
// Ganzes Board zurueck (inkl. blurred). Falls die id schon existiert (Edge-Case),
// neue uid vergeben, damit nichts ueberschrieben wird.
const restored = structuredClone(entry.item);
if (boards.some(b => b.id === restored.id)) restored.id = uid();
boards.push(restored);
await saveBoards();
} else {
const restored = structuredClone(entry.item);
let target = entry.originBoardId ? boards.find(b => b.id === entry.originBoardId) : null;
let toInbox = false;
if (!target) {
// Ursprungs-Board weg -> in die Inbox (Page-Wrapper ensureInboxBoard aus Phase 1).
target = await ensureInboxBoard();
toInbox = true;
}
target.bookmarks.push(restored);
await saveBoards();
if (toInbox) {
await HellionDialog.alert(t('trash.restored_to_inbox'), { type: 'info', title: t('trash.restored_to_inbox.title') });
}
}
trash = trash.filter(e => e !== entry);
await saveTrash();
renderTrash();
renderBoards();
}
/**
* Loescht einen einzelnen Papierkorb-Eintrag endgueltig (mit Confirm).
* @param {Object} entry
*/
async function deleteTrashEntryForever(entry) {
const ok = await HellionDialog.confirm(
t('trash.delete_forever_confirm'),
{ type: 'danger', title: t('trash.delete_forever_confirm.title'), confirmText: t('trash.delete_forever') }
);
if (!ok) return;
trash = trash.filter(e => e !== entry);
await saveTrash();
renderTrash();
}
/**
* Leert den gesamten Papierkorb (mit Confirm).
*/
async function emptyTrash() {
if (trash.length === 0) return;
const ok = await HellionDialog.confirm(
t('trash.empty_confirm', { count: trash.length }),
{ type: 'danger', title: t('trash.empty_confirm.title'), confirmText: t('trash.empty_btn') }
);
if (!ok) return;
trash = [];
await saveTrash();
renderTrash();
}
// ---- APPLY SETTINGS ----
function applySettings() {
const body = document.body;
@@ -456,11 +89,6 @@ function applySettings() {
const imgRefBtn = document.querySelector('[data-action="image-ref"]');
if (imgRefBtn) imgRefBtn.classList.toggle('hidden', !settings.imageRefEnabled);
// A11y: aria-checked aller role=switch-Toggles an den realen checked-State angleichen
document.querySelectorAll('.toggle input[role="switch"]').forEach(cb => {
cb.setAttribute('aria-checked', cb.checked ? 'true' : 'false');
});
// Toolbar-Position
document.body.classList.toggle('toolbar-left', settings.toolbarPos === 'left');
const toolbarPosEl = document.getElementById('settingToolbarPos');
@@ -470,11 +98,7 @@ function applySettings() {
const langEl = document.getElementById('settingLanguage');
if (langEl) langEl.value = settings.language || 'auto';
if (settings.theme === 'custom') {
applyCustomTheme(settings.customTheme);
} else {
applyTheme(settings.theme || 'nebula', !!settings.bgUrl);
}
if (settings.bgUrl && isValidBgUrl(settings.bgUrl)) {
document.getElementById('bgLayer').style.backgroundImage = `url('${settings.bgUrl}')`;
@@ -498,67 +122,17 @@ function bindSettingsEvents() {
});
// Theme-Picker (Cards im Theme-Modal)
const themeCards = document.querySelectorAll('.theme-card');
function selectThemeCard(card) {
document.querySelectorAll('.theme-card').forEach(card => {
card.addEventListener('click', async () => {
const name = card.dataset.value;
if (!name) return Promise.resolve();
// Custom: VOR dem name===settings.theme-Guard, damit ein Re-Klick das Panel wieder oeffnet.
if (name === 'custom') {
settings.theme = 'custom';
if (!settings.customTheme) settings.customTheme = { ...CUSTOM_DEFAULTS };
themeCards.forEach(c => c.setAttribute('aria-pressed', c === card ? 'true' : 'false'));
applyCustomTheme(settings.customTheme); // setzt data-theme + Inline-Vars; bgUrl UNANGETASTET (Koexistenz)
syncCustomPickers();
document.getElementById('themeBuilderPanel').classList.remove('hidden');
return saveSettings();
}
if (name === settings.theme) return Promise.resolve();
if (!name || name === settings.theme) return;
settings.theme = name;
settings.bgUrl = '';
document.getElementById('bgUrlInput').value = '';
clearCustomTheme(); // Inline-Vars weg beim Rueckwechsel auf ein Preset
document.getElementById('themeBuilderPanel').classList.add('hidden');
// aria-pressed synchron halten — applyTheme/switchTheme pflegt nur die .active-Klasse, nicht ARIA
themeCards.forEach(c => c.setAttribute('aria-pressed', c === card ? 'true' : 'false'));
switchTheme(name); // WICHTIG: switchTheme aus Phase 4 (View-Transition-Wrapper), NICHT applyTheme direkt — sonst geht der Theme-Fade verloren
return saveSettings();
}
themeCards.forEach(card => {
card.addEventListener('click', () => selectThemeCard(card));
card.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectThemeCard(card);
}
});
});
// Theme-Builder Picker
const TB_PICKERS = [['tbAccent', 'accent'], ['tbBg', 'bgPrimary'], ['tbBoard', 'bgBoard'],
['tbText', 'textPrimary'], ['tbTextSec', 'textSecondary'], ['tbTextMuted', 'textMuted']];
TB_PICKERS.forEach(([id, key]) => {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('input', () => { // live waehrend des Ziehens
if (!settings.customTheme) settings.customTheme = { ...CUSTOM_DEFAULTS };
settings.customTheme[key] = el.value;
settings.theme = 'custom';
applyCustomTheme(settings.customTheme);
});
el.addEventListener('change', () => saveSettings()); // persistiert beim Loslassen/Schliessen
});
const tbReset = document.getElementById('tbReset');
if (tbReset) {
tbReset.addEventListener('click', async () => {
settings.customTheme = { ...CUSTOM_DEFAULTS };
applyCustomTheme(settings.customTheme);
syncCustomPickers();
applyTheme(name, false);
await saveSettings();
});
}
});
// Accordion initialisieren
initAccordion();
@@ -589,7 +163,6 @@ function bindSettingsEvents() {
const el = document.getElementById(id);
if (el) {
el.addEventListener('change', async e => {
e.target.setAttribute('aria-checked', e.target.checked ? 'true' : 'false');
fn(e.target.checked);
await saveSettings();
});
@@ -604,9 +177,7 @@ function bindSettingsEvents() {
// Background URL (im Theme-Modal)
document.getElementById('btnChangeBg').addEventListener('click', () => {
// toggle() liefert true, wenn 'hidden' jetzt gesetzt ist -> Hinweis exakt parallel schalten
const isNowHidden = document.getElementById('bgInputRow').classList.toggle('hidden');
document.getElementById('bgUrlHint').classList.toggle('hidden', isNowHidden);
document.getElementById('bgInputRow').classList.toggle('hidden');
});
document.getElementById('btnApplyBg').addEventListener('click', async () => {
const url = document.getElementById('bgUrlInput').value.trim();
@@ -630,14 +201,8 @@ function bindSettingsEvents() {
const reader = new FileReader();
reader.onload = async ev => {
if (!isValidBgUrl(ev.target.result)) return;
let bg = ev.target.result;
try {
bg = await downscaleBgImage(bg); // Quota-Schutz: verkleinern + WebP
} catch {
// Downscale fehlgeschlagen -> Original-Upload nutzen (besser als gar kein Bild)
}
settings.bgUrl = bg;
document.getElementById('bgLayer').style.backgroundImage = `url('${bg}')`;
settings.bgUrl = ev.target.result;
document.getElementById('bgLayer').style.backgroundImage = `url('${ev.target.result}')`;
await saveSettings();
};
reader.onerror = () => {
@@ -674,10 +239,6 @@ function bindSettingsEvents() {
Onboarding.start();
});
// Papierkorb leeren
const btnEmptyTrash = document.getElementById('btnEmptyTrash');
if (btnEmptyTrash) btnEmptyTrash.addEventListener('click', emptyTrash);
// Reset All
document.getElementById('btnResetAll').addEventListener('click', async () => {
const ok = await HellionDialog.confirm(
@@ -686,14 +247,11 @@ function bindSettingsEvents() {
);
if (!ok) return;
boards = [];
trash = [];
settings = { compact: false, shortenTitles: false, newTab: true, showDesc: false,
hideExtra: false, visibleCount: 10, bgUrl: '', theme: 'nebula',
showSearch: true, searchEngine: 'google', toolbarPos: 'right',
imageRefEnabled: false, language: 'auto', customTheme: null };
clearCustomTheme();
imageRefEnabled: false, language: 'auto' };
await saveBoards();
await saveTrash();
await saveSettings();
setLanguage('auto');
applySettings();
+4 -69
View File
@@ -5,17 +5,6 @@
let boards = [];
// Papierkorb als EIGENER Store-Key (nicht im boards-Payload), isoliert das Quota-Risiko (CR-04/TRASH-02).
// Eintrag-Schema: { item, type: 'bookmark'|'board', deletedAt, originBoardId }
let trash = [];
// Papierkorb: Auto-Cleanup-Fenster und harte Obergrenze (Quota-Schutz, TRASH-04).
// 30 Tage in Millisekunden; ueber dieser Zeit wird ein Eintrag beim Laden auto-geloescht.
const TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
// Max. Anzahl trash-Eintraege. Bei Ueberlauf werden die aeltesten zuerst verworfen,
// damit der Papierkorb nicht das 10-MB-Storage-Limit sprengt (kein blindes Wachstum).
const TRASH_MAX_ENTRIES = 100;
let settings = {
compact: false,
shortenTitles: false,
@@ -25,7 +14,6 @@ let settings = {
visibleCount: 10,
bgUrl: '',
theme: 'nebula',
customTheme: null,
showSearch: true,
searchEngine: 'google',
toolbarPos: 'right',
@@ -33,8 +21,9 @@ let settings = {
language: 'auto'
};
// uid() lebt jetzt in quicksave-core.js (globalThis.uid), damit Seite und
// Background-Worker dieselbe ID-Erzeugung teilen. Hier bewusst KEINE eigene Deklaration.
function uid() {
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
}
function escHtml(str) {
return String(str)
@@ -54,9 +43,7 @@ function getDefaultBoards() {
{ id: uid(), title: 'MDN Web Docs', url: 'https://developer.mozilla.org', desc: '' },
{ id: uid(), title: 'Next.js Docs', url: 'https://nextjs.org/docs', desc: '' },
],
blurred: false,
locked: false,
pos: { x: 40, y: 110 }
blurred: false
}
];
}
@@ -65,58 +52,6 @@ async function saveBoards() {
await Store.set('boards', boards);
}
async function saveTrash() {
await Store.set('trash', trash);
}
/**
* Legt einen Eintrag in den Papierkorb. Klont das Objekt (structuredClone),
* damit der trash-Eintrag nicht per Referenz an boards[] haengt und nach dem
* Restore-Loop konsistent bleibt. Setzt deletedAt. Erzwingt die harte Obergrenze
* TRASH_MAX_ENTRIES (Quota-Schutz, TRASH-04): bei Ueberlauf fallen die aeltesten
* Eintraege heraus. Speichern uebernimmt der Aufrufer (saveTrash()).
* @param {{ item: Object, type: 'bookmark'|'board', originBoardId: (string|null) }} entry
*/
function pushToTrash({ item, type, originBoardId }) {
const entry = {
item: structuredClone(item),
type,
originBoardId: originBoardId ?? null,
deletedAt: Date.now()
};
trash.push(entry);
// Aelteste zuerst kappen, falls die Obergrenze ueberschritten ist.
if (trash.length > TRASH_MAX_ENTRIES) {
trash.sort((a, b) => a.deletedAt - b.deletedAt);
trash = trash.slice(trash.length - TRASH_MAX_ENTRIES);
}
return entry; // fuer Rollback im Delete-Handler bei Save-Fehler (W-b/Quota)
}
// Page-seitiger Wrapper um das DOM-freie ensureInbox() aus quicksave-core.js.
// ensureInbox() mutiert das globale boards-Array in-place; wir persistieren nur,
// wenn die Inbox neu angelegt wurde, und geben das Inbox-Board-Objekt zurueck
// (fuer Quick-Save-/Restore-Pfade).
async function ensureInboxBoard() {
const before = boards.length;
const inbox = ensureInbox(boards); // global aus quicksave-core.js; mutiert boards in-place
if (boards.length !== before) {
await saveBoards();
}
return inbox;
}
async function saveSettings() {
await Store.set('settings', settings);
}
// ---- VIEW TRANSITIONS ----
// Fuehrt eine synchrone DOM-Mutation mit nativem View-Transition-Fade aus.
// Feature-Detection-Fallback (Firefox < 144): instant. reduced-motion kappt das Fade ueber den ungeschichteten @media-Block.
function withViewTransition(mutate) {
if (document.startViewTransition) {
document.startViewTransition(mutate);
} else {
mutate();
}
}