Publish Untrace Desktop 1.6.1 with offline batch workflows

This commit is contained in:
Dario P. committed 2026-10-25 01:22:14 +02:00
1 parent 99b4b22352
commit 7ce81b0727
47 files changed
+5958 -1208

No files matched your search

+5 -123
View File
@@ -1,126 +1,8 @@
<script>
import { onMount } from "svelte";
import { theme } from "./lib/theme.js";
import TopBar from "./lib/TopBar.svelte";
import Tool from "./lib/Tool.svelte";
import Removals from "./lib/Removals.svelte";
import HowItWorks from "./lib/HowItWorks.svelte";
import Faq from "./lib/Faq.svelte";
import Footer from "./lib/Footer.svelte";
import Toast from "./lib/Toast.svelte";
let toasts = [];
let nextId = 0;
const assurances = [
{
title: "Runs in your browser",
detail: "No upload, no queue, no server that could keep a copy.",
},
{
title: "Reads the whole file",
detail: "EXIF, XMP, IPTC and the JUMBF boxes that hold C2PA data.",
},
{
title: "Shows its work",
detail: "Every finding is listed before anything is changed.",
},
];
function notify(event) {
const id = nextId++;
toasts = [...toasts, { id, ...event.detail }];
setTimeout(() => dismiss(id), 5000);
}
function dismiss(id) {
toasts = toasts.filter((toast) => toast.id !== id);
}
<script>
import { onMount } from 'svelte';
import { theme } from './lib/theme.js';
import Workspace from './lib/Workspace.svelte';
onMount(() => theme.init());
</script>
<TopBar />
<main>
<div class="wrap">
<section class="lede">
<p class="eyebrow">C2PA · AI credentials · EXIF</p>
<h1>Remove content credentials from an image</h1>
<p>
Untrace strips C2PA content credentials, AI generation tags, GPS
coordinates and EXIF metadata from a photo — free, and without the file
ever leaving your device.
</p>
<div class="chip-row">
<span class="chip">Free</span>
<span class="chip">No sign-in</span>
<span class="chip">Open source</span>
</div>
</section>
<Tool on:notify={notify} />
<ul class="assurances">
{#each assurances as item}
<li>
<h2>{item.title}</h2>
<p>{item.detail}</p>
</li>
{/each}
</ul>
<div class="stack sections">
<Removals />
<HowItWorks />
<Faq />
</div>
</div>
</main>
<Footer />
<Toast {toasts} {dismiss} />
<style>
main {
padding-bottom: var(--space-8);
}
.assurances {
display: grid;
gap: var(--space-3);
margin-top: var(--space-5);
list-style: none;
}
@media (min-width: 768px) {
.assurances {
grid-template-columns: repeat(3, 1fr);
gap: var(--space-4);
}
}
.assurances li {
display: grid;
gap: var(--space-1);
padding-left: var(--space-4);
border-left: var(--border-w) solid var(--border-strong);
}
.assurances h2 {
font-size: var(--fs-2);
line-height: var(--lh-2);
font-weight: 600;
}
.assurances p {
color: var(--text-3);
font-size: var(--fs-1);
line-height: var(--lh-2);
}
.sections {
margin-top: var(--space-9);
}
</style>
<Workspace />
+198 -639
View File
@@ -1,640 +1,199 @@
/* ==========================================================================
Untrace — application stylesheet
==========================================================================
Design system: RealBrain v2.0. Tokens come from ./tokens.css (vendored,
never edited by hand). Everything below composes those tokens only.
Refresh the vendored copy with:
curl -o src/tokens.css https://realbrain.cc/design-system/tokens.css
========================================================================== */
@import "./tokens.css";
/* --------------------------------------------------------------------------
Values the design system has no token for yet. Flagged for the system
owners rather than sprinkled around as raw values:
NEW TOKEN --border-w: 1px — hairline width, used by every surface
NEW TOKEN --topbar-h: 56px — sticky product bar height
(derived: --space-7 + --space-2)
NEW TOKEN breakpoints — 560px / 768px / 900px
Breakpoints cannot be custom properties: a media query is evaluated before
the cascade, so var() is not available there. Until the system ships
@custom-media or a documented scale, every file in this app uses exactly
these three widths and no others.
-------------------------------------------------------------------------- */
:root {
--border-w: 1px;
--topbar-h: calc(var(--space-7) + var(--space-2));
}
/* An <a> carrying .btn is a button, not prose: the base underline-on-hover
from tokens.css does not belong on it. */
a.btn:hover {
text-decoration: none;
}
/* --------------------------------------------------------------------------
Layout
-------------------------------------------------------------------------- */
.wrap {
width: 100%;
max-width: var(--max-marketing);
margin-inline: auto;
padding-inline: var(--space-4);
}
@media (min-width: 768px) {
.wrap {
padding-inline: var(--space-6);
}
}
.stack {
display: grid;
gap: var(--space-7);
}
@media (min-width: 768px) {
.stack {
gap: var(--space-9);
}
}
/* --------------------------------------------------------------------------
Product bar
-------------------------------------------------------------------------- */
.topbar {
position: sticky;
top: 0;
z-index: 20;
height: var(--topbar-h);
display: flex;
align-items: center;
background: var(--bg);
border-bottom: var(--border-w) solid var(--border);
}
.topbar .inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.wordmark {
display: inline-flex;
align-items: center;
gap: var(--space-3);
color: var(--text-1);
font-size: var(--fs-3);
line-height: var(--lh-3);
font-weight: 600;
letter-spacing: -0.01em;
}
.wordmark:hover {
text-decoration: none;
}
/* --------------------------------------------------------------------------
Section headings
-------------------------------------------------------------------------- */
.eyebrow {
font-family: var(--font-mono);
font-size: var(--fs-1);
line-height: var(--lh-1);
color: var(--accent);
letter-spacing: 0.1em;
text-transform: uppercase;
}
.section-head {
display: grid;
gap: var(--space-2);
margin-bottom: var(--space-5);
max-width: calc(var(--space-9) * 6);
}
.section-head h2 {
font-size: var(--fs-6);
line-height: var(--lh-6);
font-weight: 600;
letter-spacing: -0.01em;
}
.section-head p {
color: var(--text-2);
font-size: var(--fs-3);
line-height: var(--lh-3);
}
@media (min-width: 768px) {
.section-head h2 {
font-size: var(--fs-7);
line-height: var(--lh-7);
}
}
/* --------------------------------------------------------------------------
Hero
-------------------------------------------------------------------------- */
.lede {
display: grid;
gap: var(--space-4);
justify-items: start;
padding-block: var(--space-7) var(--space-6);
}
.lede h1 {
font-size: var(--fs-7);
line-height: var(--lh-7);
font-weight: 600;
letter-spacing: -0.02em;
max-width: calc(var(--space-9) * 8);
}
.lede p {
color: var(--text-2);
font-size: var(--fs-4);
line-height: var(--lh-4);
max-width: calc(var(--space-9) * 6);
}
@media (min-width: 768px) {
.lede {
padding-block: var(--space-8) var(--space-7);
}
.lede h1 {
font-size: var(--fs-8);
line-height: var(--lh-8);
}
}
/* --------------------------------------------------------------------------
Chips — neutral mono labels for formats and counts
-------------------------------------------------------------------------- */
.chip {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-3);
border: var(--border-w) solid var(--border);
border-radius: var(--radius-full);
background: var(--surface);
color: var(--text-2);
font-family: var(--font-mono);
font-size: var(--fs-1);
line-height: var(--lh-1);
white-space: nowrap;
}
.chip-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
/* --------------------------------------------------------------------------
Stepper
-------------------------------------------------------------------------- */
.stepper {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-2);
padding-bottom: var(--space-4);
border-bottom: var(--border-w) solid var(--border);
margin-bottom: var(--space-5);
}
.step {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
color: var(--text-3);
font-size: var(--fs-1);
line-height: var(--lh-1);
font-weight: 500;
}
.step .num {
display: inline-grid;
place-content: center;
flex: none;
width: var(--space-5);
height: var(--space-5);
border-radius: var(--radius-full);
border: var(--border-w) solid var(--border-strong);
font-family: var(--font-mono);
font-size: var(--fs-1);
line-height: 1;
transition:
background var(--dur-2) var(--ease),
border-color var(--dur-2) var(--ease),
color var(--dur-2) var(--ease);
}
.step .label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.step[data-state="current"] {
color: var(--text-1);
}
.step[data-state="current"] .num {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-fg);
}
.step[data-state="done"] {
color: var(--text-2);
}
.step[data-state="done"] .num {
background: var(--surface);
border-color: var(--success);
color: var(--success);
}
/* --------------------------------------------------------------------------
Dropzone
-------------------------------------------------------------------------- */
.dropzone {
display: grid;
justify-items: center;
align-content: center;
gap: var(--space-3);
width: 100%;
min-height: calc(var(--space-9) * 2);
padding: var(--space-7) var(--space-5);
text-align: center;
background: var(--bg);
border: var(--border-w) dashed var(--border-strong);
border-radius: var(--radius-md);
color: inherit;
font-family: var(--font-ui);
cursor: pointer;
transition:
border-color var(--dur-2) var(--ease),
background var(--dur-2) var(--ease);
}
.dropzone:hover:not(:disabled) {
border-color: var(--text-3);
background: var(--surface);
}
.dropzone[data-drag="true"] {
border-color: var(--accent);
border-style: solid;
background: color-mix(in oklab, var(--accent), transparent 94%);
}
.dropzone:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.dropzone .glyph {
display: grid;
place-content: center;
width: calc(var(--space-6) + var(--space-2));
height: calc(var(--space-6) + var(--space-2));
border-radius: var(--radius-md);
border: var(--border-w) solid var(--border-strong);
color: var(--text-3);
transition: color var(--dur-2) var(--ease);
}
.dropzone[data-drag="true"] .glyph {
color: var(--accent);
border-color: var(--accent);
}
.dropzone .title {
font-size: var(--fs-4);
line-height: var(--lh-4);
font-weight: 600;
color: var(--text-1);
}
.dropzone .hint {
font-size: var(--fs-2);
line-height: var(--lh-2);
color: var(--text-3);
max-width: calc(var(--space-9) * 4);
}
/* --------------------------------------------------------------------------
Image preview
-------------------------------------------------------------------------- */
.preview {
display: grid;
place-content: center;
padding: var(--space-4);
border: var(--border-w) solid var(--border);
border-radius: var(--radius-md);
background: var(--bg);
}
.preview img {
display: block;
max-width: 100%;
max-height: 34vh;
border-radius: var(--radius-sm);
}
@media (min-width: 768px) {
.preview img {
max-height: 40vh;
}
}
/* --------------------------------------------------------------------------
Key/value + finding rows
-------------------------------------------------------------------------- */
.rows {
display: grid;
border: var(--border-w) solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--surface);
}
.rows > * + * {
border-top: var(--border-w) solid var(--border);
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
font-size: var(--fs-2);
line-height: var(--lh-2);
}
.row .k {
color: var(--text-2);
min-width: 0;
}
.row .v {
font-family: var(--font-mono);
color: var(--text-1);
text-align: right;
white-space: nowrap;
}
/* --------------------------------------------------------------------------
Raw metadata dump
-------------------------------------------------------------------------- */
.raw {
margin: 0;
padding: var(--space-4);
max-height: calc(var(--space-9) * 3);
overflow: auto;
background: var(--bg);
border: var(--border-w) solid var(--border);
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: var(--fs-1);
line-height: var(--lh-2);
color: var(--text-2);
white-space: pre-wrap;
overflow-wrap: anywhere;
tab-size: 2;
}
/* --------------------------------------------------------------------------
Disclosure (FAQ + expandable panels)
-------------------------------------------------------------------------- */
.disclosure {
border: var(--border-w) solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
}
.disclosure + .disclosure {
margin-top: var(--space-2);
}
.disclosure > summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-4);
min-height: var(--control-h);
cursor: pointer;
list-style: none;
font-size: var(--fs-3);
line-height: var(--lh-3);
font-weight: 500;
color: var(--text-1);
transition: color var(--dur-1) var(--ease);
}
.disclosure > summary::-webkit-details-marker {
display: none;
}
.disclosure > summary:hover {
color: var(--accent);
}
.disclosure > summary .caret {
flex: none;
color: var(--text-3);
transition: transform var(--dur-2) var(--ease);
}
.disclosure[open] > summary .caret {
transform: rotate(180deg);
}
.disclosure .body {
padding: 0 var(--space-4) var(--space-4);
color: var(--text-2);
font-size: var(--fs-2);
line-height: var(--lh-3);
}
.disclosure .body a {
text-decoration: underline;
}
/* --------------------------------------------------------------------------
Numbered how-to steps
-------------------------------------------------------------------------- */
.howto {
display: grid;
gap: var(--space-4);
counter-reset: howto;
list-style: none;
}
@media (min-width: 768px) {
.howto {
grid-template-columns: repeat(3, 1fr);
gap: var(--space-5);
}
}
.howto > li {
counter-increment: howto;
display: grid;
gap: var(--space-2);
padding: var(--space-5);
background: var(--surface);
border: var(--border-w) solid var(--border);
border-radius: var(--radius-md);
}
.howto > li::before {
content: counter(howto);
display: grid;
place-content: center;
width: var(--space-6);
height: var(--space-6);
border-radius: var(--radius-sm);
background: var(--elevated);
border: var(--border-w) solid var(--border-strong);
color: var(--accent);
font-family: var(--font-mono);
font-size: var(--fs-2);
line-height: 1;
margin-bottom: var(--space-1);
}
.howto h3 {
font-size: var(--fs-3);
line-height: var(--lh-3);
font-weight: 600;
}
.howto p {
color: var(--text-2);
font-size: var(--fs-2);
line-height: var(--lh-2);
}
/* --------------------------------------------------------------------------
Feature / removal grid
-------------------------------------------------------------------------- */
.tiles {
display: grid;
gap: var(--space-3);
grid-template-columns: 1fr;
}
@media (min-width: 560px) {
.tiles {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 900px) {
.tiles {
grid-template-columns: repeat(3, 1fr);
}
}
.tile {
display: grid;
gap: var(--space-1);
padding: var(--space-4);
background: var(--surface);
border: var(--border-w) solid var(--border);
border-radius: var(--radius-md);
}
.tile h3 {
font-size: var(--fs-2);
line-height: var(--lh-2);
font-weight: 600;
color: var(--text-1);
}
.tile p {
font-size: var(--fs-1);
line-height: var(--lh-2);
color: var(--text-3);
}
/* --------------------------------------------------------------------------
Mobile action bar — keeps the one primary action reachable with a thumb
-------------------------------------------------------------------------- */
.action-bar {
position: sticky;
bottom: 0;
z-index: 10;
display: grid;
gap: var(--space-2);
padding: var(--space-3) var(--space-4)
calc(var(--space-3) + env(safe-area-inset-bottom));
margin-inline: calc(var(--space-4) * -1);
background: var(--surface);
border-top: var(--border-w) solid var(--border);
}
@media (min-width: 768px) {
.action-bar {
position: static;
display: flex;
gap: var(--space-3);
padding: 0;
margin-inline: 0;
background: transparent;
border-top: 0;
}
}
.btn-block {
width: 100%;
}
@media (min-width: 768px) {
.btn-block {
width: auto;
}
}
/* --------------------------------------------------------------------------
Footer
-------------------------------------------------------------------------- */
.site-footer {
padding-block: var(--space-7);
border-top: var(--border-w) solid var(--border);
color: var(--text-3);
font-size: var(--fs-2);
line-height: var(--lh-2);
}
/* --------------------------------------------------------------------------
Utilities that earn their keep
-------------------------------------------------------------------------- */
.visually-hidden {
position: absolute;
width: var(--border-w);
height: var(--border-w);
margin: calc(var(--border-w) * -1);
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.divider {
height: var(--border-w);
background: var(--border);
border: 0;
@import './tokens.css';
@import './themes.css';
/* Existing project proposal: one-pixel hairlines; breakpoints at 560/768/900. */
:root { --border-w: 1px; }
html, body, #app { height: 100%; }
body { overflow: hidden; }
button, select, summary, a { -webkit-app-region: no-drag; }
a.btn:hover { text-decoration: none; }
.visually-hidden { position: absolute; width: 0; height: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.desktop-app { height: 100dvh; display: grid; grid-template-columns: var(--space-8) minmax(0, 1fr) calc(var(--space-9) * 3 + var(--space-6)); grid-template-rows: var(--space-8) minmax(0, 1fr) var(--space-6); }
.app-rail { grid-row: 1 / -1; display: flex; flex-direction: column; align-items: center; gap: var(--space-5); padding: var(--space-4) var(--space-2); background: var(--surface); border-right: var(--border-w) solid var(--border); }
.brand-mark { display: grid; place-items: center; height: var(--space-6); color: var(--text-1); }
.rail-current { width: var(--space-7); height: var(--space-7); display: grid; place-items: center; background: var(--bg); border: var(--border-w) solid var(--border-strong); border-radius: var(--radius-md); color: var(--text-1); }
.rail-bottom { margin-top: auto; display: grid; gap: var(--space-2); }
.app-header { grid-column: 2 / -1; display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding-inline: var(--space-5); border-bottom: var(--border-w) solid var(--border); background: var(--surface); }
.app-heading { display: flex; align-items: center; gap: var(--space-4); min-width: 0; }
.app-header, .app-rail { color: var(--text-1); }
.app-heading h1 { font-size: var(--fs-4); line-height: var(--lh-4); font-weight: 600; }
.app-heading > span:last-child { font-size: var(--fs-2); color: var(--text-3); }
.header-divider { height: var(--space-4); border-left: var(--border-w) solid var(--border-strong); }
.local-badge { display: flex; align-items: center; gap: var(--space-2); font-size: var(--fs-1); color: var(--text-3); }
.editor { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
.editor-toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding: var(--space-3) var(--space-5); }
.toolbar-file { display: flex; align-items: center; gap: var(--space-2); min-width: 0; color: var(--text-3); font-size: var(--fs-2); }
.toolbar-file svg { flex: none; }
.toolbar-file span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.toolbar-actions { display: flex; gap: var(--space-1); flex: none; }
.image-workspace { position: relative; flex: 1; min-height: 0; margin-inline: var(--space-5); display: flex; flex-direction: column; border: var(--border-w) solid var(--border); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; }
.welcome { flex: 1; min-height: 0; overflow: auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--space-4); text-align: center; padding: var(--space-6); border: var(--border-w) dashed var(--border-strong); border-radius: var(--radius-md); margin: var(--space-3); }
.image-emblem { position: relative; width: var(--space-8); height: var(--space-8); border: var(--border-w) solid var(--border-strong); border-radius: var(--radius-lg); display: grid; place-items: center; color: var(--text-2); background: var(--bg); margin-bottom: var(--space-3); }
.image-emblem > svg { width: var(--space-6); height: var(--space-6); }
.emblem-badge { position: absolute; right: calc(var(--space-2) * -1); bottom: calc(var(--space-2) * -1); display: grid; place-items: center; width: var(--space-6); height: var(--space-6); background: var(--surface); border: var(--border-w) solid var(--border-strong); border-radius: var(--radius-md); }
.welcome-copy { display: grid; gap: var(--space-3); margin-bottom: var(--space-2); }
.overline { font-size: var(--fs-1); line-height: var(--lh-1); font-weight: 500; color: var(--text-3); }
.welcome h2 { font-size: var(--fs-7); line-height: var(--lh-7); font-weight: 500; letter-spacing: -0.025em; }
.welcome-copy > p:last-child { font-size: var(--fs-2); line-height: var(--lh-3); color: var(--text-3); }
.shortcut-hint { color: var(--text-3); display: flex; gap: var(--space-2); align-items: center; font-size: var(--fs-1); }
kbd { font: inherit; font-family: var(--font-mono); font-size: var(--fs-1); padding: var(--space-1) var(--space-2); border: var(--border-w) solid var(--border); border-radius: var(--radius-sm); color: var(--text-2); }
.format-hint { display: flex; flex-wrap: wrap; gap: var(--space-2); color: var(--text-3); font-size: var(--fs-1); margin-top: var(--space-5); }
.format-hint span { color: var(--border-strong); }
.workspace-note { display: flex; justify-content: center; align-items: center; gap: var(--space-2); padding: var(--space-4); color: var(--text-3); font-size: var(--fs-1); line-height: var(--lh-1); }
.workspace-note svg { flex: none; }
.preview-controls, .preview-bottom { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding: var(--space-2) var(--space-3); flex: none; }
.preview-controls { border-bottom: var(--border-w) solid var(--border); }
.preview-bottom { border-top: var(--border-w) solid var(--border); color: var(--text-3); font-size: var(--fs-1); font-family: var(--font-mono); }
.view-switch, .zoom-controls { display: flex; align-items: center; gap: var(--space-1); }
/* State indicator sits on the wrapper, retaining the shared button styles. */
.view-switch { border: var(--border-w) solid var(--border); border-radius: var(--radius-sm); }
[aria-pressed='true'] { text-decoration: underline; text-underline-offset: var(--space-1); }
.preview-label { font-size: var(--fs-1); color: var(--text-3); font-family: var(--font-mono); }
.preview-viewport { flex: 1; min-height: 0; overflow: auto; display: flex; padding: var(--space-5); background: var(--bg); }
.workspace-image { display: block; max-width: 100%; max-height: 100%; object-fit: contain; margin: auto; }
.actual-size .workspace-image { max-width: none; max-height: none; flex-shrink: 0; }
.preview-unavailable { display: grid; justify-items: center; align-content: center; gap: var(--space-3); text-align: center; margin: auto; color: var(--text-3); }
.preview-unavailable h2 { font-size: var(--fs-4); font-weight: 500; color: var(--text-2); }
.preview-unavailable p { font-size: var(--fs-2); }
.drop-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; gap: var(--space-4); align-items: center; justify-content: center; background: var(--surface); border: var(--border-w) dashed var(--accent); color: var(--text-1); pointer-events: none; z-index: 5; }
.drop-overlay h2 { font-size: var(--fs-5); font-weight: 500; }
.processing-overlay { position: absolute; left: 50%; bottom: var(--space-8); transform: translateX(-50%); display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); background: var(--surface); border: var(--border-w) solid var(--border-strong); border-radius: var(--radius-md); font-size: var(--fs-2); white-space: nowrap; }
.inspector { display: flex; flex-direction: column; min-height: 0; min-width: 0; background: var(--surface); border-left: var(--border-w) solid var(--border); }
.inspector-heading { display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); padding: var(--space-5); }
.inspector-heading h2 { font-size: var(--fs-3); line-height: var(--lh-3); font-weight: 500; }
.subtle { color: var(--text-3); font-size: var(--fs-1); }
.inspector-scroll { flex: 1; min-height: 0; overflow: auto; scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
.inspector-section { padding: var(--space-4) var(--space-5); border-top: var(--border-w) solid var(--border); }
.section-label { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); margin-bottom: var(--space-4); color: var(--text-3); }
.section-label h3 { color: var(--text-2); font-size: var(--fs-2); line-height: var(--lh-2); font-weight: 500; }
.source-placeholder { display: flex; align-items: center; gap: var(--space-3); color: var(--text-3); }
.source-placeholder p { font-size: var(--fs-2); color: var(--text-2); }
.source-placeholder span { display: block; font-size: var(--fs-1); color: var(--text-3); }
.source-name { font-size: var(--fs-2); font-weight: 500; overflow-wrap: anywhere; margin-bottom: var(--space-3); }
.properties { display: grid; gap: var(--space-2); font-size: var(--fs-1); }
.properties > div, .shortcut-list > div { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); }
.properties dt { color: var(--text-3); }
.properties dd { color: var(--text-2); font-family: var(--font-mono); }
.section-description { font-size: var(--fs-1); color: var(--text-3); margin-bottom: var(--space-4); }
.coverage-list { list-style: none; display: grid; gap: var(--space-2); }
.coverage-list li { display: flex; align-items: center; gap: var(--space-3); font-size: var(--fs-2); color: var(--text-3); }
.count-badge { font-size: var(--fs-1); color: var(--text-3); }
.metadata-list { display: grid; gap: var(--space-4); list-style: none; }
.metadata-list p { font-size: var(--fs-1); color: var(--text-3); line-height: var(--lh-1); margin-top: var(--space-1); overflow-wrap: anywhere; }
.metadata-details { margin-top: var(--space-5); font-size: var(--fs-1); color: var(--text-3); }
.metadata-details summary { cursor: pointer; padding-block: var(--space-2); }
.metadata-details pre { font-family: var(--font-mono); font-size: var(--fs-1); line-height: var(--lh-2); white-space: pre-wrap; overflow-wrap: anywhere; max-height: calc(var(--space-9) * 3); overflow: auto; padding: var(--space-3); background: var(--bg); border-radius: var(--radius-sm); margin-top: var(--space-2); }
.scan-note { display: flex; gap: var(--space-2); font-size: var(--fs-1); line-height: var(--lh-2); color: var(--text-3); }
.scan-note svg { flex: none; margin-top: var(--space-1); }
.export-fields { display: grid; gap: var(--space-4); }
.export-hint { font-size: var(--fs-1); line-height: var(--lh-2); color: var(--text-3); }
.inspector-action { display: grid; gap: var(--space-3); padding: var(--space-5); border-top: var(--border-w) solid var(--border); }
.inspector-action p { font-size: var(--fs-1); line-height: var(--lh-2); color: var(--text-3); }
.inspector-action .output-name { overflow-wrap: anywhere; color: var(--text-2); }
.output-name span { display: block; color: var(--text-3); }
.app-statusbar { grid-column: 2 / -1; display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding-inline: var(--space-4); border-top: var(--border-w) solid var(--border); background: var(--surface); font-size: var(--fs-1); color: var(--text-3); }
.statusbar-local { display: flex; align-items: center; gap: var(--space-2); }
.inline-notice { display: flex; align-items: center; gap: var(--space-3); margin: 0 var(--space-5) var(--space-3); font-size: var(--fs-2); color: var(--error); }
.inline-notice > svg { flex: none; }
.inline-notice > span { flex: 1; }
.help-content { display: grid; gap: var(--space-5); }
.help-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); }
.help-heading h2 { font-size: var(--fs-6); line-height: var(--lh-6); font-weight: 500; margin-top: var(--space-2); }
.help-content h3 { font-size: var(--fs-3); font-weight: 500; }
.help-content > p { font-size: var(--fs-2); line-height: var(--lh-3); color: var(--text-2); }
.shortcut-list { display: grid; gap: var(--space-2); font-size: var(--fs-2); color: var(--text-2); }
.help-footer { display: flex; justify-content: space-between; flex-wrap: wrap; gap: var(--space-2); padding-top: var(--space-4); border-top: var(--border-w) solid var(--border); font-size: var(--fs-1); color: var(--text-3); }
@media (max-width: 900px) {
.desktop-app { grid-template-columns: var(--space-8) minmax(0, 1fr) calc(var(--space-9) * 3); }
.editor-toolbar { padding-inline: var(--space-3); }
.image-workspace { margin-inline: var(--space-3); }
.welcome { padding: var(--space-4); }
.welcome h2 { font-size: var(--fs-6); line-height: var(--lh-6); }
.desktop-break { display: none; }
.statusbar-tip { display: none; }
}
@media (max-width: 768px) {
body { overflow: auto; }
.desktop-app { height: auto; min-height: 100dvh; grid-template-columns: var(--space-8) minmax(0, 1fr); grid-template-rows: var(--space-8) auto auto var(--space-6); }
.app-rail { position: sticky; top: 0; height: 100dvh; }
.editor { grid-column: 2; min-height: calc(var(--space-9) * 6); }
.image-workspace { min-height: calc(var(--space-9) * 4); }
.preview-viewport { height: calc(var(--space-9) * 4); flex: none; }
.inspector { grid-column: 2; border-left: 0; border-top: var(--border-w) solid var(--border); }
.inspector-scroll { overflow: visible; }
.app-statusbar { grid-column: 2; }
.local-badge { display: none; }
.app-heading { gap: var(--space-3); }
}
/* Theme picker: palette previews use the same semantic tokens as the app. */
.appearance-content { display: grid; gap: var(--space-4); }
.appearance-description { font-size: var(--fs-2); line-height: var(--lh-2); color: var(--text-2); }
.theme-choices { border: 0; min-width: 0; display: grid; gap: var(--space-4); }
.system-choice { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3); border: var(--border-w) solid var(--border-strong); border-radius: var(--radius-md); cursor: pointer; }
.system-choice > span { flex: 1; }
.system-choice > svg { color: var(--text-3); }
.theme-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); }
.theme-choice { display: flex; flex-direction: column; gap: var(--space-3); padding: var(--space-2); border: var(--border-w) solid var(--border); border-radius: var(--radius-md); cursor: pointer; min-width: 0; }
.theme-choice:hover { border-color: var(--border-strong); }
.theme-choice.selected { border-color: var(--text-2); }
.theme-choice:focus-within, .system-choice:focus-within { outline: var(--border-w) solid var(--accent); outline-offset: var(--space-1); }
.theme-choice-label { display: flex; align-items: center; gap: var(--space-2); padding: 0 var(--space-1) var(--space-1); }
.theme-choice-label input, .system-choice input { flex: none; }
.theme-name { display: block; font-size: var(--fs-2); line-height: var(--lh-2); font-weight: 500; color: var(--text-1); }
.theme-description { display: block; font-size: var(--fs-1); line-height: var(--lh-1); color: var(--text-3); }
.theme-mini { height: var(--space-8); display: grid; grid-template-columns: var(--space-3) minmax(0, 1fr) var(--space-6); grid-template-rows: var(--space-3) 1fr; gap: var(--border-w); padding: var(--border-w); background: var(--border); border-radius: var(--radius-sm); overflow: hidden; }
.mini-rail { grid-row: 1 / -1; background: var(--surface); }
.mini-header { grid-column: 2 / -1; background: var(--surface); }
.mini-canvas { display: grid; place-items: center; background: var(--bg); }
.mini-image { display: grid; place-items: center; color: var(--text-2); }
.mini-inspector { display: flex; flex-direction: column; gap: var(--space-1); padding: var(--space-1); background: var(--surface); }
.mini-inspector > span { height: var(--space-1); background: var(--border-strong); border-radius: var(--radius-sm); }
.mini-inspector .mini-action { margin-top: auto; height: var(--space-2); background: var(--accent); }
.appearance-note { display: flex; align-items: flex-start; gap: var(--space-2); font-size: var(--fs-1); line-height: var(--lh-2); color: var(--text-3); }
.appearance-note svg { flex: none; }
.appearance-footer { display: flex; justify-content: flex-end; }
/* Batch queue and inspection use the same semantic palette as the workspace. */
.batch-workspace { flex: 1; min-height: 0; display: flex; flex-direction: column; margin-inline: var(--space-5); border: var(--border-w) solid var(--border); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; }
.batch-heading { display: grid; gap: var(--space-2); padding: var(--space-5); border-bottom: var(--border-w) solid var(--border); }
.batch-heading h2 { font-size: var(--fs-5); line-height: var(--lh-5); font-weight: 500; }
.batch-heading p, .batch-progress { font-size: var(--fs-1); line-height: var(--lh-2); color: var(--text-3); }
.batch-list { flex: 1; min-height: 0; overflow: auto; }
.batch-row { display: grid; grid-template-columns: minmax(0, 1fr) auto var(--space-9); align-items: center; gap: var(--space-3); padding: var(--space-2) var(--space-3); border-bottom: var(--border-w) solid var(--border); }
.batch-row.selected { background: var(--bg); }
.batch-file { min-width: 0; justify-content: flex-start; }
.batch-file span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.batch-row-status { display: grid; gap: var(--space-1); font-size: var(--fs-1); }
.batch-size { font: var(--fs-1)/var(--lh-1) var(--font-mono); color: var(--text-3); text-align: right; }
.batch-progress { padding: var(--space-3) var(--space-5); border-top: var(--border-w) solid var(--border); }
.batch-detail { overflow-wrap: anywhere; font-size: var(--fs-1); line-height: var(--lh-2); color: var(--text-2); margin-block: var(--space-3); }
.gps-badge { display: flex; gap: var(--space-1); align-items: center; color: var(--warning); }
.location-warning { display: flex; gap: var(--space-2); padding: var(--space-4); margin: var(--space-3); border: var(--border-w) solid var(--border-strong); border-radius: var(--radius-sm); font-size: var(--fs-1); line-height: var(--lh-2); background: var(--bg); }
.location-warning > svg { flex: none; color: var(--warning); }
.location-warning strong { font-weight: 500; color: var(--text-1); }
.location-warning p { color: var(--text-2); overflow-wrap: anywhere; }
.size-preview { display: grid; gap: var(--space-3); margin-top: var(--space-4); }
.comparison-dialog { max-height: calc(100dvh - var(--space-6)); overflow: hidden; }
.comparison-content { display: flex; flex-direction: column; gap: var(--space-4); max-height: calc(100dvh - var(--space-9)); }
.comparison-content .overline { overflow-wrap: anywhere; }
.comparison-scroll { overflow: auto; min-height: 0; }
.comparison-scroll th, .comparison-scroll td { white-space: pre-wrap; overflow-wrap: anywhere; max-width: calc(var(--space-9) * 3); font-size: var(--fs-1); font-weight: 400; }
.comparison-scroll th { font-weight: 500; }
@media (max-width: 768px) {
.editor-toolbar { flex-wrap: wrap; }
.batch-workspace { min-height: calc(var(--space-9) * 3); max-height: calc(var(--space-9) * 5); }
.batch-row { grid-template-columns: minmax(0, 1fr) auto; }
.batch-size { display: none; }
.processing-overlay { max-width: calc(100% - var(--space-4)); flex-wrap: wrap; justify-content: center; white-space: normal; }
}
.appearance-dialog { max-height: calc(100dvh - var(--space-6)); overflow: hidden; }
.appearance-content { max-height: calc(100dvh - var(--space-9)); grid-template-rows: auto auto minmax(0, 1fr) auto auto; }
.theme-choices { overflow-y: auto; padding: var(--space-1); scrollbar-gutter: stable; }
.theme-group { display: grid; gap: var(--space-3); }
.theme-group-title { font-size: var(--fs-2); line-height: var(--lh-2); font-weight: 500; color: var(--text-2); }
.theme-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
@media (max-width: 768px) {
.theme-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
+47
View File
@@ -0,0 +1,47 @@
<script>
import { THEMES, theme } from './theme.js';
import Icon from './Icon.svelte';
let dialog;
export function open() { dialog.showModal(); }
export function isOpen() { return !!dialog?.open; }
</script>
<dialog class="modal appearance-dialog" bind:this={dialog} aria-labelledby="appearance-title" style:margin="auto" style:width="calc(100% - var(--space-6))" style:max-width="calc(var(--space-9) * 8)">
<div class="appearance-content">
<div class="help-heading">
<div><span class="overline">Make it yours</span><h2 id="appearance-title">Appearance</h2></div>
<button class="btn btn-ghost" type="button" on:click={() => dialog.close()} aria-label="Close appearance"><Icon name="close" /></button>
</div>
<div class="appearance-description">Choose a theme for your workspace. Your choice is saved automatically.</div>
<fieldset class="theme-choices">
<legend class="visually-hidden">Workspace theme</legend>
<label class="system-choice">
<input class="radio" type="radio" name="workspace-theme" value="system" checked={$theme === 'system'} on:change={() => theme.select('system')} />
<span><span class="theme-name">Follow Windows</span><span class="theme-description">Match the system light or dark setting.</span></span>
<Icon name="monitor" />
</label>
{#each [{ name: 'Editor favourites', items: THEMES.filter(item => item.editor) }, { name: 'Untrace originals', items: THEMES.filter(item => !item.editor) }] as group}
<div class="theme-group">
<h3 class="theme-group-title">{group.name}</h3>
<div class="theme-grid">
{#each group.items as item}
<label class="theme-choice" class:selected={$theme === item.id}>
<div class="theme-mini" data-theme={item.id} aria-hidden="true">
<span class="mini-rail"></span><span class="mini-header"></span>
<span class="mini-canvas"><span class="mini-image"><Icon name="image" size="sm" /></span></span>
<span class="mini-inspector"><span></span><span></span><span class="mini-action"></span></span>
</div>
<span class="theme-choice-label">
<input class="radio" type="radio" name="workspace-theme" value={item.id} checked={$theme === item.id} on:change={() => theme.select(item.id)} />
<span><span class="theme-name">{item.name}</span><span class="theme-description">{item.description}</span></span>
</span>
</label>
{/each}
</div>
</div>
{/each}
</fieldset>
<div class="appearance-note"><Icon name="image" size="sm" />Themes change the workspace, never your image or exported colours.</div>
<div class="appearance-footer"><button class="btn btn-secondary" type="button" on:click={() => dialog.close()}>Done</button></div>
</div>
</dialog>
+153
View File
@@ -0,0 +1,153 @@
<script>
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
import Icon from './Icon.svelte';
import ExportSettings from './ExportSettings.svelte';
import MetadataComparison from './MetadataComparison.svelte';
import { processImage } from './processing.js';
import { isAccepted, formatMetadata } from './metadata.js';
import { locationSummary } from './metadata-comparison.js';
import { formatBytes } from './clean.js';
export let files = [];
export let format;
export let quality;
export let busy = false;
export let stateLabel = 'Batch ready';
const dispatch = createEventDispatcher();
let rows = files.filter(entry => isAccepted(entry.file)).map((entry, id) => ({ ...entry, id, status: 'Queued', detail: '' }));
let selected = 0;
let controller, comparison, output;
let message = '', operation = '', cancelled = false, choosing = false;
$: current = rows[selected];
$: available = rows.filter(row => row.result && row.status !== 'Saved');
$: remaining = rows.filter(row => isAccepted(row.file) && !row.result);
$: totalSize = rows.reduce((sum, row) => sum + (row.result?.size || 0), 0);
$: summary = ['Cleaned', 'Saved', 'Failed', 'Cancelled'].map(status => `${rows.filter(row => row.status === status).length} ${status.toLowerCase()}`).join(' · ');
$: gps = locationSummary(current?.metadata);
function update(row, values) { Object.assign(row, values); rows = [...rows]; }
function start(label) { controller = new AbortController(); cancelled = false; busy = true; operation = label; message = ''; }
function finish() { busy = false; operation = ''; stateLabel = cancelled ? 'Batch cancelled' : 'Batch ready'; }
export function isDialogOpen() { return comparison?.isOpen(); }
export function cancel() {
if (!busy) return;
cancelled = true;
controller?.abort();
stateLabel = operation === 'Saving' ? 'Finishing current save' : 'Cancelling';
message = operation === 'Saving' ? 'The current file will finish saving. Remaining files will not be saved.' : 'Stopped processing. Completed results are kept; remaining images can be retried.';
}
async function scan() {
start('Scanning');
try {
for (const row of rows) {
if (cancelled) break;
if (row.status !== 'Queued') continue;
update(row, { status: 'Scanning' }); stateLabel = `Scanning ${row.id + 1} of ${rows.length}`;
try {
const metadata = await processImage('scan', row.file, {}, controller.signal);
update(row, { metadata, status: 'Ready', detail: metadata.scanError || '' });
} catch (error) {
update(row, { status: error.name === 'AbortError' ? 'Cancelled' : 'Failed', detail: error.name === 'AbortError' ? 'Scan cancelled.' : error.message });
}
}
if (cancelled) rows = rows.map(row => row.status === 'Queued' ? { ...row, status: 'Cancelled', detail: 'Not scanned.' } : row);
} finally { finish(); }
}
export async function clean() {
if (busy || choosing || !remaining.length) return;
start('Cleaning');
const pending = [...remaining];
try {
for (let index = 0; index < pending.length && !cancelled; index++) {
const row = pending[index];
update(row, { status: 'Cleaning', detail: '' }); stateLabel = `Cleaning ${index + 1} of ${pending.length}`;
try {
if (!row.metadata) update(row, { metadata: await processImage('scan', row.file, {}, controller.signal) });
const result = await processImage('clean', row.file, { format, quality: format === 'auto' ? 0.92 : Number(quality) }, controller.signal);
if (result.fallback) throw new Error('Could not decode or rewrite this image. Convert it to JPEG or PNG and try again.');
update(row, { result, status: 'Cleaned', detail: result.wasSecondPass ? 'Second pass: JPEG on white.' : '' });
} catch (error) {
update(row, { status: error.name === 'AbortError' ? 'Cancelled' : 'Failed', detail: error.name === 'AbortError' ? 'Cleaning cancelled.' : error.message });
}
}
if (cancelled) for (const row of pending) if (!row.result && !['Failed', 'Cancelled'].includes(row.status)) update(row, { status: 'Cancelled', detail: 'Not processed.' });
} finally { finish(); }
}
async function chooseOutput() {
if (busy || choosing) return;
choosing = true;
try {
if (!window.untraceDesktop) throw new Error('Folder saving is available in the Windows desktop app.');
const next = await window.untraceDesktop.chooseOutputFolder();
if (next) output = next;
} catch (error) { message = error.message; }
finally { choosing = false; }
}
export async function save() {
if (busy || choosing || !available.length) return;
if (!output) await chooseOutput();
if (!output) return;
start('Saving');
const pending = [...available];
try {
for (let index = 0; index < pending.length && !cancelled; index++) {
const row = pending[index];
stateLabel = `Saving ${index + 1} of ${pending.length}`;
update(row, { status: 'Saving' });
try {
const bytes = new Uint8Array(await row.result.blob.arrayBuffer());
if (cancelled) { update(row, { status: 'Cleaned' }); break; }
const saved = await window.untraceDesktop.saveOutput(output.token, row.result.filename, bytes);
update(row, { status: 'Saved', detail: saved.path, saved, result: { ...row.result, blob: null } });
} catch (error) { update(row, { status: 'Failed', detail: `Save failed: ${error.message}` }); }
}
} finally { finish(); }
}
function settingsChanged() {
rows = rows.map(row => isAccepted(row.file) ? { ...row, result: null, saved: null, status: row.metadata ? 'Ready' : 'Queued', detail: '' } : row);
message = 'Export settings changed. Clean again to prepare new copies. Files already saved stay unchanged.';
}
onMount(scan);
onDestroy(() => { cancelled = true; controller?.abort(); });
</script>
<main class="editor">
<div class="editor-toolbar"><div class="toolbar-file"><Icon name="folder" size="sm" /><span>{rows.length} files</span></div><button class="btn btn-secondary" disabled={busy || choosing} on:click={() => dispatch('close')}>Close batch</button></div>
{#if message}<div class="inline-notice" role="status"><Icon name="info" size="sm" /><span>{message}</span></div>{/if}
<section class="batch-workspace" aria-label="Batch files">
<div class="batch-heading"><h2>Batch cleaning</h2><p>{summary}</p></div>
<div class="batch-list">
{#each rows as row}
<div class="batch-row" class:selected={selected === row.id}>
<button class="btn btn-ghost batch-file" aria-pressed={selected === row.id} on:click={() => selected = row.id}><Icon name="image" size="sm" /><span title={row.name}>{row.name}</span></button>
<div class="batch-row-status"><span class="status" class:ok={row.status === 'Cleaned' || row.status === 'Saved'} class:err={row.status === 'Failed'} class:warn={row.status === 'Cancelled'} class:info={['Scanning', 'Cleaning', 'Saving'].includes(row.status)}>{row.status}</span>{#if locationSummary(row.metadata)}<span class="gps-badge"><Icon name="pin" size="sm" />GPS</span>{/if}</div>
<span class="batch-size">{row.result ? formatBytes(row.result.size) : row.file ? formatBytes(row.file.size) : '—'}</span>
</div>
{/each}
</div>
<div class="batch-progress" role="status">{busy ? stateLabel : `${rows.length} files · ${formatBytes(totalSize)} prepared output`}</div>
</section>
<div class="workspace-note"><Icon name="shield" size="sm" /><span>Existing files are never overwritten. Duplicate names receive a number.</span></div>
</main>
<aside class="inspector" aria-label="Batch inspector">
<div class="inspector-heading"><h2>Inspector</h2><span class="subtle">Batch</span></div>
<div class="inspector-scroll">
<section class="inspector-section"><div class="section-label"><h3>Selected file</h3></div>
{#if current}<p class="source-name">{current.name}</p><p class="status" class:err={current.status === 'Failed'}>{current.status}</p>
{#if current.detail}<p class="batch-detail">{current.detail}</p>{/if}
{#if gps}<div class="location-warning" role="status"><Icon name="pin" size="sm" /><div><strong>Location data in original</strong><p>{gps}</p></div></div>{/if}
{#if current.result}<dl class="properties"><div><dt>Original size</dt><dd>{formatBytes(current.file.size)}</dd></div><div><dt>Export size</dt><dd>{formatBytes(current.result.size)}</dd></div><div><dt>Size change</dt><dd>{Math.round(current.result.sizeDelta)}%</dd></div></dl><button class="btn btn-secondary" on:click={() => comparison.open(current.metadata, current.result.metadata, current.name)}>Compare metadata</button>{/if}
{#if current.metadata}<details class="metadata-details"><summary>View original metadata</summary><pre>{formatMetadata(current.metadata)}</pre></details>{/if}
{/if}
</section>
<section class="inspector-section"><div class="section-label"><h3>Export</h3><Icon name="download" size="sm" /></div><ExportSettings bind:format bind:quality {busy} on:change={settingsChanged} /><p class="export-hint batch-detail">Clean the batch to calculate exact sizes before saving.</p></section>
<section class="inspector-section"><div class="section-label"><h3>Output folder</h3><Icon name="folder" size="sm" /></div><p class="batch-detail">{output?.path || 'Choose where to save the cleaned copies.'}</p><button class="btn btn-secondary" on:click={chooseOutput} disabled={busy || choosing}>{output ? 'Change output folder' : 'Choose output folder'}</button><p class="export-hint batch-detail">Subfolders are combined here. Your source folders stay unchanged.</p></section>
</div>
<div class="inspector-action">
{#if busy}<p role="status">{stateLabel}</p><button class="btn btn-secondary" on:click={cancel} disabled={cancelled}>{cancelled ? 'Cancelling…' : 'Cancel processing'}</button>
{:else}
{#if available.length}<p>{available.length} files ready to save · {formatBytes(available.reduce((sum, row) => sum + row.result.size, 0))}</p><button class="btn btn-primary" disabled={choosing} on:click={save}>Save cleaned files</button>{/if}
{#if remaining.length}<button class="btn" class:btn-primary={!available.length} class:btn-secondary={!!available.length} disabled={choosing} on:click={clean}>{rows.some(row => row.result) ? 'Clean remaining images' : 'Clean batch'}</button>{/if}
{#if !available.length && !remaining.length}<p>Batch complete. {rows.filter(row => row.status === 'Saved').length} copies saved.</p>{/if}
{/if}
</div>
</aside>
<MetadataComparison bind:this={comparison} />
+15
View File
@@ -0,0 +1,15 @@
<script>
import { createEventDispatcher } from 'svelte';
import { saveExportSettings } from './export-settings.js';
export let format = 'auto';
export let quality = 0.92;
export let busy = false;
const dispatch = createEventDispatcher();
function changed() { saveExportSettings(format, quality); dispatch('change'); }
</script>
<div class="export-fields">
<div class="field"><label for="export-format">File format</label><select id="export-format" class="select" bind:value={format} on:change={changed} disabled={busy}><option value="auto">Automatic</option><option value="image/png">PNG · preserve transparency</option><option value="image/jpeg">JPEG · smaller file</option></select></div>
{#if format === 'image/jpeg'}<div class="field"><label for="export-quality">JPEG quality</label><select id="export-quality" class="select" bind:value={quality} on:change={changed} disabled={busy}><option value={0.92}>High · 92%</option><option value={1}>Maximum · 100%</option><option value={0.85}>Balanced · 85%</option></select></div>{/if}
<p class="export-hint">{format === 'image/png' ? 'Preserves transparency. Files may be larger.' : format === 'image/jpeg' ? 'Transparency is flattened onto white.' : 'Small PNGs stay PNG. Other images become JPEG on white.'} Settings are remembered.</p>
</div>
+6
View File
@@ -7,6 +7,12 @@
export let size = "md";
const paths = {
palette: "M12 3a9 9 0 1 0 0 18h1a2 2 0 0 0 1-3.7 1.5 1.5 0 0 1 .8-2.8H17a4 4 0 0 0 4-4C21 6.4 17 3 12 3ZM7 10h.01M10 7h.01M15 7h.01M18 10h.01",
monitor: "M3 4h18v13H3V4Zm5 17h8m-4-4v4",
folder: "M3 7V5h6l2 2h10v12H3V7Zm0 3h18",
plus: "M12 5v14M5 12h14",
help: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm-3-12a3 3 0 0 1 6 0c0 2-3 2-3 4m0 3v.1",
info: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-11v6m0-9v.1",
upload:
"M12 16V4m0 0L8 8m4-4 4 4M4 15v2a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3v-2",
shield: "M12 3l7 3v6c0 4-3 7-7 8-4-1-7-4-7-8V6l7-3Zm-2.5 8.5 2 2 3.5-3.5",
+24
View File
@@ -0,0 +1,24 @@
<script>
import Icon from './Icon.svelte';
import { compareMetadata } from './metadata-comparison.js';
let dialog;
let before, after, filename;
$: rows = compareMetadata(before, after);
export function open(original, cleaned, name) { before = original; after = cleaned; filename = name; dialog.showModal(); }
export function isOpen() { return !!dialog?.open; }
</script>
<dialog class="modal comparison-dialog" bind:this={dialog} aria-labelledby="comparison-title" style:margin="auto" style:width="calc(100% - var(--space-6))" style:max-width="calc(var(--space-9) * 10)">
<div class="comparison-content">
<div class="help-heading"><div><span class="overline">{filename}</span><h2 id="comparison-title">Metadata comparison</h2></div><button class="btn btn-ghost" aria-label="Close comparison" on:click={() => dialog.close()}><Icon name="close" /></button></div>
<p class="export-hint">Fields detected in the original and the encoded copy. Image headers and an encoder-generated colour profile can remain or change. “Not detected afterward” is a scan result, not proof that all metadata is gone.</p>
{#if before?.scanError || after?.scanError}<p class="status warn">Some metadata could not be read. Missing fields are unverified where parsing failed.</p>{/if}
<div class="comparison-scroll">
<table class="table"><thead><tr><th scope="col">Field</th><th scope="col">Original</th><th scope="col">Cleaned copy</th><th scope="col">Result</th></tr></thead><tbody>
{#each rows as row}<tr><th scope="row">{row.name}</th><td>{row.before}</td><td>{row.after}</td><td>{row.status}</td></tr>{/each}
{#if !rows.length}<tr><td colspan="4">No recognized fields to compare.</td></tr>{/if}
</tbody></table>
</div>
<div class="appearance-footer"><button class="btn btn-secondary" on:click={() => dialog.close()}>Done</button></div>
</div>
</dialog>
+3 -1
View File
@@ -28,7 +28,7 @@
<div class="side">
<p class="cap">
After <span class="status ok">clean</span>
After <span class:ok={!result.fallback} class:err={result.fallback} class="status">{result.fallback ? "Not cleaned" : "clean"}</span>
</p>
<figure class="preview">
<img src={result.url} alt="Result, after cleaning" />
@@ -60,11 +60,13 @@
</p>
{/if}
{#if !result.fallback}
<ul class="removed">
{#each removed as item}
<li><span class="status ok">{item}</span></li>
{/each}
</ul>
{/if}
</div>
</div>
+1 -1
View File
@@ -57,7 +57,7 @@
}
async function clean() {
if (!file) return;
if (!file || stage === "cleaning") return;
stage = "cleaning";
result = await cleanImage(file);
stage = "done";
+297
View File
@@ -0,0 +1,297 @@
<script>
import { onDestroy, tick } from 'svelte';
import Icon from './Icon.svelte';
import Appearance from './Appearance.svelte';
import { ACCEPTED_EXTENSIONS, isAccepted, findings, countEntries, formatMetadata } from './metadata.js';
import { formatBytes } from './clean.js';
import BatchWorkspace from './BatchWorkspace.svelte';
import MetadataComparison from './MetadataComparison.svelte';
import ExportSettings from './ExportSettings.svelte';
import { loadExportSettings } from './export-settings.js';
import { processImage } from './processing.js';
import { locationSummary } from './metadata-comparison.js';
import { readDroppedFiles } from './drop-files.js';
const savedSettings = loadExportSettings();
let batchFiles = [], batch, batchBusy = false, batchState = 'Batch ready';
let comparison, folderInput, controller, estimate = null;
const version = __APP_VERSION__;
const appName = __APP_NAME__;
let appearance;
let file = null;
let metadata = null;
let previewUrl = null;
let result = null;
let stage = 'idle';
let view = 'original';
let zoom = 'fit';
let format = savedSettings.format;
let quality = savedSettings.quality;
let dimensions = '';
let previewFailed = false;
let dragging = false;
let dragDepth = 0;
let input;
let help;
let saveLink;
let notice = '';
let generation = 0;
$: busy = batchBusy || ['scanning', 'cleaning', 'estimating', 'collecting'].includes(stage);
$: gps = locationSummary(metadata);
$: ready = !!result && !result.fallback;
$: list = findings(metadata);
$: entries = countEntries(metadata);
$: currentUrl = view === 'cleaned' && ready ? result.url : previewUrl;
$: stateLabel = batchFiles.length ? batchState : stage === 'collecting' ? 'Reading folder' : stage === 'estimating' ? 'Calculating export size' : stage === 'scanning' ? 'Scanning image' : stage === 'cleaning' ? 'Cleaning image' : result?.fallback ? 'Could not clean' : ready ? 'Ready to save' : file ? 'Ready to clean' : 'Ready';
function releaseResult() {
if (result?.url) URL.revokeObjectURL(result.url);
result = null;
estimate = null;
view = 'original';
}
function release() {
if (previewUrl) URL.revokeObjectURL(previewUrl);
previewUrl = null;
releaseResult();
}
function reset() {
if (busy) return;
generation++;
release();
file = null; metadata = null; dimensions = ''; notice = ''; previewFailed = false;
stage = 'idle'; zoom = 'fit';
}
function dialogOpen() { return help?.open || appearance?.isOpen() || comparison?.isOpen() || batch?.isDialogOpen(); }
function cancel() {
if (batchFiles.length) { batch?.cancel(); return; }
generation++; controller?.abort();
if (file && !metadata) metadata = { scanError: 'Original metadata scan was cancelled.' };
stage = file ? 'review' : 'idle';
notice = 'Processing cancelled. Your original is unchanged.';
}
function offerFiles(entries, forceBatch = false) {
if (busy || dialogOpen()) return;
entries = entries.filter(entry => isAccepted(entry.file));
if (!entries.length) { notice = 'No supported images found. Choose JPEG, PNG, AVIF, HEIC or TIFF images.'; return; }
if (entries.length === 1 && !forceBatch) { offer(entries[0].file); return; }
reset(); batchFiles = entries;
}
function closeBatch() { batchFiles = []; batchBusy = false; reset(); }
async function offer(next) {
if (!next || busy || dialogOpen()) return;
if (!isAccepted(next)) {
notice = 'That file type is not supported. Choose a JPEG, PNG, AVIF, HEIC or TIFF image.';
return;
}
release();
const run = ++generation;
file = next; metadata = null; dimensions = ''; previewFailed = false; notice = ''; zoom = 'fit';
previewUrl = URL.createObjectURL(next);
stage = 'scanning'; controller = new AbortController();
try {
const scanned = await processImage('scan', next, {}, controller.signal);
if (run !== generation) return;
metadata = scanned;
if (scanned.scanError) notice = scanned.scanError;
stage = 'review';
} catch {
if (run !== generation) return;
stage = 'review';
notice = 'The metadata could not be read. You can try rewriting the image, or open another file.';
}
}
function choose() { if (!busy && !batchFiles.length) input.click(); }
function onChange(event) { offerFiles([...event.target.files].map(file => ({ file, name: file.webkitRelativePath || file.name })), event.target === folderInput); event.target.value = ''; }
async function clean(sizeOnly = false) {
if (batchFiles.length) { batch?.clean(); return; }
if (!file || busy) return;
const run = generation;
stage = sizeOnly ? 'estimating' : 'cleaning'; notice = ''; controller = new AbortController();
try {
const cleaned = estimate || await processImage('clean', file, { format, quality: format === 'auto' ? 0.92 : Number(quality) }, controller.signal);
if (run !== generation) return;
if (sizeOnly && !cleaned.fallback) { estimate = cleaned; stage = 'review'; return; }
releaseResult();
result = { ...cleaned, url: URL.createObjectURL(cleaned.blob) };
view = result.fallback ? 'original' : 'cleaned'; stage = 'done';
} catch (error) {
if (run !== generation) return;
stage = 'review'; notice = error.message || 'Could not process this image. Try another file.';
}
}
function settingsChanged() {
estimate = null;
if (result) { releaseResult(); stage = 'review'; }
}
function onPaste(event) {
if (busy || dialogOpen() || batchFiles.length) return;
const item = [...(event.clipboardData?.items || [])].find(entry => entry.type.startsWith('image/'));
if (item) { event.preventDefault(); offer(item.getAsFile()); }
}
async function onDrop(event) {
event.preventDefault(); dragging = false; dragDepth = 0;
if (busy || dialogOpen() || batchFiles.length) return;
controller = new AbortController();
const run = ++generation;
stage = 'collecting';
try {
const entries = await readDroppedFiles(event.dataTransfer?.items || [], event.dataTransfer?.files || [], controller.signal);
if (run !== generation) return;
stage = file ? 'review' : 'idle'; await tick(); offerFiles(entries);
} catch (error) {
if (run !== generation) return;
stage = file ? 'review' : 'idle'; notice = 'Could not read the dropped files. Try Open folder or Open image.';
}
}
function onDragEnter(event) {
if (![...(event.dataTransfer?.types || [])].includes('Files') || busy || dialogOpen() || batchFiles.length) return;
event.preventDefault(); dragDepth++; dragging = true;
}
function onDragLeave() { dragDepth = Math.max(0, dragDepth - 1); if (!dragDepth) dragging = false; }
function onKey(event) {
if (dialogOpen()) return;
if (event.ctrlKey || event.metaKey) {
if (event.key.toLowerCase() === 'o') { event.preventDefault(); choose(); }
if (event.key.toLowerCase() === 's') { event.preventDefault(); if (batchFiles.length) batch?.save(); else if (ready) saveLink?.click(); }
if (event.key === 'Enter') { event.preventDefault(); if (!ready) clean(false); }
}
if (event.key === 'Escape' && busy) { cancel(); return; }
if (event.key === 'Escape' && !batchFiles.length && !busy && !['SELECT', 'INPUT'].includes(event.target.tagName)) reset();
if (event.key === 'F1') { event.preventDefault(); help.showModal(); }
}
onDestroy(() => { generation++; controller?.abort(); release(); });
</script>
<svelte:window on:keydown={onKey} on:paste={onPaste} on:dragenter={onDragEnter} on:dragleave={onDragLeave} on:dragover|preventDefault on:drop={onDrop} />
<div class="desktop-app density-comfortable">
<aside class="app-rail" aria-label="Application tools">
<div class="brand-mark" title={appName}><Icon name="shield" size="lg" /></div>
<div class="rail-current" aria-label="Image cleaner" title="Image cleaner"><Icon name="image" /></div>
<div class="rail-bottom">
<button class="btn btn-ghost" type="button" on:click={() => help.showModal()} aria-label="Help and shortcuts" title="Help and shortcuts (F1)"><Icon name="help" /></button>
<button class="btn btn-ghost" type="button" on:click={() => appearance.open()} aria-label="Choose a theme" title="Appearance"><Icon name="palette" /></button>
</div>
</aside>
<header class="app-header">
<div class="app-heading"><h1>{appName}</h1><span class="header-divider"></span><span>Image cleaner</span></div>
<span class="local-badge"><Icon name="lock" size="sm" /> Local workspace</span>
</header>
{#if batchFiles.length}
<BatchWorkspace bind:this={batch} files={batchFiles} bind:format bind:quality bind:busy={batchBusy} bind:stateLabel={batchState} on:close={closeBatch} />
{:else}
<main class="editor">
<div class="editor-toolbar">
<div class="toolbar-file"><Icon name="image" size="sm" /><span title={file?.name}>{file?.name || 'Untitled workspace'}</span></div>
<div class="toolbar-actions">
{#if file}<button class="btn btn-ghost" type="button" on:click={reset} disabled={busy} title="Close image (Esc)" aria-label="Close image"><Icon name="close" size="sm" /></button>{/if}
<button class="btn btn-ghost" type="button" on:click={() => folderInput.click()} disabled={busy}>Open folder</button>
<button class="btn btn-secondary" type="button" on:click={choose} disabled={busy} title="Open image (Ctrl+O)"><Icon name="folder" size="sm" />Open image</button>
</div>
</div>
{#if notice}<div class="inline-notice" role="alert"><Icon name="info" size="sm" /><span>{notice}</span><button class="btn btn-ghost" type="button" aria-label="Dismiss message" on:click={() => notice = ''}><Icon name="close" size="sm" /></button></div>{/if}
<section class="image-workspace" class:dragging aria-label="Image preview">
{#if !file}
<div class="welcome">
<div class="image-emblem"><Icon name="image" size="lg" /><span class="emblem-badge"><Icon name="shield" size="sm" /></span></div>
<div class="welcome-copy"><p class="overline">A fresh start for your images</p><h2>Keep the image.<br />Leave the metadata.</h2><p>Drop images or a folder to inspect and remove<br class="desktop-break" /> the information stored inside it.</p></div>
<button class="btn btn-primary" type="button" on:click={choose}><Icon name="plus" size="sm" />Choose an image</button>
<span class="shortcut-hint">or paste from clipboard <kbd>Ctrl V</kbd></span>
<div class="format-hint">JPEG <span>·</span> PNG <span>·</span> AVIF <span>·</span> HEIC <span>·</span> TIFF</div>
</div>
{:else}
<div class="preview-controls">
<div class="view-switch" aria-label="Preview version">
<button class="btn btn-ghost" type="button" aria-pressed={view === 'original'} on:click={() => view = 'original'}>Original</button>
<button class="btn btn-ghost" type="button" aria-pressed={view === 'cleaned'} disabled={!ready} on:click={() => view = 'cleaned'}>Cleaned copy</button>
</div>
<span class="preview-label">{view === 'cleaned' && ready ? formatBytes(result.size) : formatBytes(file.size)}</span>
</div>
<div class="preview-viewport" class:actual-size={zoom === 'actual'}>
{#if previewFailed}
<div class="preview-unavailable"><Icon name="image" size="lg" /><h2>Preview unavailable</h2><p>This format could not be decoded.<br />Try converting it to JPEG or PNG first.</p></div>
{:else}
<img class="workspace-image" src={currentUrl} alt={view === 'cleaned' ? 'Cleaned image preview' : 'Original image preview'} on:load={event => dimensions = `${event.target.naturalWidth} × ${event.target.naturalHeight}`} on:error={() => previewFailed = true} />
{/if}
</div>
<div class="preview-bottom"><span>{dimensions || 'Image preview'}</span><div class="zoom-controls"><button class="btn btn-ghost" type="button" aria-pressed={zoom === 'fit'} on:click={() => zoom = 'fit'}>Fit</button><button class="btn btn-ghost" type="button" aria-pressed={zoom === 'actual'} on:click={() => zoom = 'actual'}>100%</button></div></div>
{/if}
{#if dragging}<div class="drop-overlay"><Icon name="upload" size="lg" /><h2>Drop to open images</h2></div>{/if}
{#if busy}<div class="processing-overlay" role="status"><span class="spinner"></span>{stateLabel}… <button class="btn btn-secondary" on:click={cancel}>Cancel processing</button></div>{/if}
</section>
<div class="workspace-note"><Icon name="shield" size="sm" /><span>Your original stays untouched. Save a separate, cleaned copy.</span></div>
</main>
<aside class="inspector" aria-label="Image inspector">
<div class="inspector-heading"><h2>Inspector</h2><span class="subtle">{file ? '1 image' : 'No image'}</span></div>
<div class="inspector-scroll">
<section class="inspector-section">
<div class="section-label"><h3>Source</h3><Icon name="file" size="sm" /></div>
{#if file}
<p class="source-name" title={file.name}>{file.name}</p>
<dl class="properties"><div><dt>File size</dt><dd>{formatBytes(file.size)}</dd></div><div><dt>Dimensions</dt><dd>{dimensions || '—'}</dd></div></dl>
{:else}
<div class="source-placeholder"><Icon name="image" /><p>No image selected<span>Open a file to get started.</span></p></div>
{/if}
</section>
{#if gps}<div class="location-warning" role="status"><Icon name="pin" size="sm" /><div><strong>Location data in original</strong><p>{gps}</p><p>Sharing this original can reveal where it was taken.</p></div></div>{/if}
<section class="inspector-section">
<div class="section-label"><h3>Metadata</h3>{#if file && !busy}<span class="count-badge">{entries} entries</span>{:else}<Icon name="scan" size="sm" />{/if}</div>
{#if stage === 'scanning'}
<p class="subtle">Inspecting the file…</p>
{:else if file && list.length}
<ul class="metadata-list">{#each list as item}<li><span class="status {item.severity}">{item.label}</span><p>{item.detail}</p></li>{/each}</ul>
<details class="metadata-details"><summary>View raw metadata</summary><pre>{formatMetadata(metadata)}</pre></details>
{:else if file}
<p class="scan-note"><Icon name="info" size="sm" /><span>No recognized metadata found. A rewrite can still remove unrecognized containers.</span></p>
{:else}
<p class="section-description">See what travels with your image.</p>
<ul class="coverage-list"><li><Icon name="pin" size="sm" />Location & GPS</li><li><Icon name="camera" size="sm" />Camera & device</li><li><Icon name="clock" size="sm" />Dates & timestamps</li><li><Icon name="shield" size="sm" />Content credentials</li><li><Icon name="file" size="sm" />EXIF, XMP & comments</li></ul>
{/if}
</section>
{#if ready}<section class="inspector-section"><div class="section-label"><h3>After cleaning</h3></div><p class="export-hint">{result.metadata?.scanError || 'Compare detected fields in the original and the cleaned copy.'}</p><button class="btn btn-secondary" on:click={() => comparison.open(metadata, result.metadata, file.name)}>Compare metadata</button></section>{/if}
<section class="inspector-section">
<div class="section-label"><h3>Export</h3><Icon name="download" size="sm" /></div>
<ExportSettings bind:format bind:quality {busy} on:change={settingsChanged} />
{#if file && !ready}<div class="size-preview">
{#if estimate}<dl class="properties"><div><dt>Export size</dt><dd>{formatBytes(estimate.size)}</dd></div><div><dt>Size change</dt><dd>{Math.round(estimate.sizeDelta)}%</dd></div></dl><p class="export-hint">Exact encoded size. This prepared output will be reused when you clean.</p>
{:else}<button class="btn btn-secondary" disabled={busy} on:click={() => clean(true)}>Preview export size</button><p class="export-hint">Calculate the actual file size before cleaning and saving.</p>{/if}
</div>{/if}
</section>
</div>
<div class="inspector-action" aria-live="polite">
{#if result?.fallback}
<span class="status err">Not cleaned</span><p>This image could not be rewritten. Convert it to JPEG or PNG and try again.</p>
<button class="btn btn-primary" type="button" on:click={choose}>Try another image</button>
{:else if ready}
<span class="status ok">Ready to save</span><p class="output-name" title={result.filename}>{result.filename}<span>{formatBytes(result.size)} · {Math.round(result.sizeDelta)}% size change</span></p>
{#if result.wasSecondPass}<p>A second pass used JPEG on white to remove remaining metadata.</p>{/if}
<a class="btn btn-primary" href={result.url} download={result.filename} bind:this={saveLink}><Icon name="download" size="sm" />Save clean image</a>
{:else}
<p>{file ? 'Rewrites the image without its embedded metadata.' : 'Your export will be ready here.'}</p>
<button class="btn" class:btn-primary={!!file} class:btn-secondary={!file} type="button" disabled={!file} aria-disabled={busy} on:click={() => clean(false)}>{#if busy}<span class="spinner"></span>{stateLabel}…{:else}<Icon name="shield" size="sm" />Clean image{/if}</button>
{/if}
</div>
</aside>
{/if}
<footer class="app-statusbar"><span class="status" class:ok={!busy} class:info={busy}>{stateLabel}</span><span class="statusbar-tip">{busy ? 'Esc to cancel processing' : batchFiles.length ? 'Ctrl Enter to clean · Ctrl S to save' : ready ? 'Ctrl S to save a copy' : file ? 'Ctrl Enter to clean' : 'Ctrl O to open an image'}</span><span class="statusbar-local"><Icon name="lock" size="sm" />On-device processing</span></footer>
</div>
<input class="visually-hidden" type="file" multiple accept={ACCEPTED_EXTENSIONS} bind:this={input} on:change={onChange} tabindex="-1" aria-label="Open image file" />
<input class="visually-hidden" type="file" webkitdirectory multiple accept={ACCEPTED_EXTENSIONS} bind:this={folderInput} on:change={onChange} tabindex="-1" aria-label="Open image folder" />
<MetadataComparison bind:this={comparison} />
<Appearance bind:this={appearance} />
<dialog class="modal" bind:this={help} aria-labelledby="help-title" style:margin="auto">
<div class="help-content"><div class="help-heading"><div><p class="overline">{appName}</p><h2 id="help-title">A little less attached.</h2></div><button type="button" class="btn btn-ghost" aria-label="Close help" on:click={() => help.close()}><Icon name="close" /></button></div>
<p>Open one image, several images, or a folder. Inspect metadata, clean, then save separate copies. Batch mode includes subfolders and gives each file a result. Use Cancel processing or Esc to stop; completed copies are kept. Everything is processed on your device.</p>
<h3>Keyboard shortcuts</h3><dl class="shortcut-list"><div><dt>Open image</dt><dd><kbd>Ctrl O</kbd></dd></div><div><dt>Paste image</dt><dd><kbd>Ctrl V</kbd></dd></div><div><dt>Clean image</dt><dd><kbd>Ctrl Enter</kbd></dd></div><div><dt>Save clean copy</dt><dd><kbd>Ctrl S</kbd></dd></div><div><dt>Close image</dt><dd><kbd>Esc</kbd></dd></div></dl>
<h3>Good to know</h3><p>JPEG and PNG work best. HEIC, TIFF and some AVIF files may need conversion first. JPEG is lossy; PNG can preserve transparency. Colour profiles, animation and additional pages are not preserved.</p><p>Metadata scanning is best effort. Removing metadata does not remove visible watermarks or identifying image content, guarantee anonymity, or prevent AI labels.</p><div class="help-footer"><span>Untrace {version} · MIT license</span><a href="https://github.com/developersharif/untrace" target="_blank" rel="noreferrer">Original project ↗</a></div></div>
</dialog>
+27 -10
View File
@@ -19,6 +19,7 @@ const SECOND_PASS_QUALITY = 0.95;
const PNG_PIXEL_LIMIT = 1000000;
function loadImage(source) {
if (typeof document === 'undefined') return createImageBitmap(source).then(img => ({ img }));
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(source);
const img = new Image();
@@ -33,11 +34,12 @@ function loadImage(source) {
function redraw(img, { type, quality, fillWhite = false }) {
return new Promise((resolve, reject) => {
const canvas = document.createElement("canvas");
const canvas = typeof document === 'undefined' ? new OffscreenCanvas(img.width, img.height) : document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas is unavailable.");
ctx.clearRect(0, 0, canvas.width, canvas.height);
// A white base stops transparent areas turning black when the second pass
@@ -50,6 +52,10 @@ function redraw(img, { type, quality, fillWhite = false }) {
ctx.globalCompositeOperation = "source-over";
ctx.drawImage(img, 0, 0);
if (canvas.convertToBlob) {
canvas.convertToBlob({ type, quality }).then(resolve, reject);
return;
}
canvas.toBlob(
(blob) =>
blob
@@ -71,7 +77,12 @@ async function stillHasMetadata(blob) {
gps: true,
xmp: true,
iptc: true,
icc: true,
// Chromium can generate a standard sRGB profile during encoding.
// It is reported in comparison, but another lossy pass cannot remove it.
icc: false,
// Required image headers describe the pixels, not removable metadata.
ihdr: false,
jfif: false,
silentErrors: true,
});
return Boolean(remaining && Object.keys(remaining).length > 0);
@@ -83,8 +94,8 @@ async function stillHasMetadata(blob) {
}
function outputName(originalName, blobType) {
const extension = originalName.split(".").pop();
const base = originalName.replace(new RegExp(`\\.${extension}$`, "i"), "");
const extension = originalName.includes(".") ? originalName.split(".").pop() : "";
const base = originalName.replace(/\.[^.]+$/, "").replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').slice(0, 180) || 'image';
let finalExtension = extension;
if (blobType === "image/jpeg") finalExtension = "jpg";
@@ -99,19 +110,22 @@ function outputName(originalName, blobType) {
* `fallback: true` means the image could not be re-encoded and the original
* bytes are being returned untouched. The caller must not claim success.
*/
export async function cleanImage(file) {
export async function cleanImage(file, { format = 'auto', quality = JPEG_QUALITY } = {}) {
let objectUrl;
let bitmap;
try {
const { img, url } = await loadImage(file);
objectUrl = url;
bitmap = img;
const keepPng =
file.type === "image/png" && img.width * img.height < PNG_PIXEL_LIMIT;
const keepPng = format === 'image/png' || (format === 'auto' &&
(file.type === "image/png" || /\.png$/i.test(file.name)) && img.width * img.height < PNG_PIXEL_LIMIT);
let blob = await redraw(img, {
type: keepPng ? "image/png" : "image/jpeg",
quality: keepPng ? undefined : JPEG_QUALITY,
quality: keepPng ? undefined : Math.min(1, Math.max(0.1, quality)),
fillWhite: !keepPng,
});
let wasSecondPass = false;
@@ -125,7 +139,8 @@ export async function cleanImage(file) {
});
wasSecondPass = true;
} finally {
URL.revokeObjectURL(second.url);
if (second.url) URL.revokeObjectURL(second.url);
second.img.close?.();
}
}
@@ -135,13 +150,15 @@ export async function cleanImage(file) {
return result(file, file, { wasSecondPass: false, fallback: true });
} finally {
if (objectUrl) URL.revokeObjectURL(objectUrl);
bitmap?.close?.();
}
}
function result(blob, originalFile, { wasSecondPass, fallback }) {
return {
blob,
url: URL.createObjectURL(blob),
filename: outputName(originalFile.name, blob.type),
filename: fallback ? originalFile.name : outputName(originalFile.name, blob.type),
size: blob.size,
originalSize: originalFile.size,
sizeDelta: originalFile.size
+29
View File
@@ -0,0 +1,29 @@
// readEntries returns batches (often 100 entries); drain each directory fully.
export async function readDroppedFiles(items, fallbackFiles, signal) {
const entries = [...items].map(item => item.webkitGetAsEntry?.()).filter(Boolean);
if (!entries.length) return [...fallbackFiles].map(file => ({ file, name: file.name }));
const files = [];
async function visit(entry, prefix = '') {
signal?.throwIfAborted();
const name = prefix + entry.name;
try {
if (entry.isFile) {
const file = await new Promise((resolve, reject) => entry.file(resolve, reject));
files.push({ file, name });
} else if (entry.isDirectory) {
const reader = entry.createReader();
while (true) {
signal?.throwIfAborted();
const group = await new Promise((resolve, reject) => reader.readEntries(resolve, reject));
if (!group.length) break;
for (const child of group) await visit(child, name + '/');
}
}
} catch (error) {
if (signal?.aborted) throw error;
files.push({ name, error: 'Could not read this file or folder.' });
}
}
for (const entry of entries) await visit(entry);
return files;
}
+12
View File
@@ -0,0 +1,12 @@
const KEY = 'untrace-export';
export function loadExportSettings() {
let saved;
try { saved = JSON.parse(localStorage.getItem(KEY)); } catch { /* Use defaults. */ }
return {
format: ['auto', 'image/png', 'image/jpeg'].includes(saved?.format) ? saved.format : 'auto',
quality: [0.85, 0.92, 1].includes(saved?.quality) ? saved.quality : 0.92,
};
}
export function saveExportSettings(format, quality) {
try { localStorage.setItem(KEY, JSON.stringify({ format, quality: Number(quality) })); } catch { /* Keep session settings. */ }
}
+18
View File
@@ -0,0 +1,18 @@
import { extractMetadata } from './metadata.js';
import { cleanImage } from './clean.js';
self.onmessage = async ({ data: { task, file, options } }) => {
try {
if (task === 'scan') {
self.postMessage({ value: await extractMetadata(file) });
return;
}
const result = await cleanImage(file, options);
URL.revokeObjectURL(result.url);
delete result.url;
if (!result.fallback) result.metadata = await extractMetadata(result.blob);
self.postMessage({ value: result });
} catch (error) {
self.postMessage({ error: error.message || 'Image processing failed.' });
}
};
+38
View File
@@ -0,0 +1,38 @@
import { sections } from './metadata.js';
function fields(metadata) {
return new Map(sections(metadata).flatMap(([section, values]) =>
Object.entries(values).map(([name, value]) => [`${section} / ${name}`, value])));
}
export function displayValue(value) {
if (value === undefined) return 'Not detected';
if (value instanceof Date) return value.toISOString();
return typeof value === 'object' ? JSON.stringify(value) : String(value);
}
export function compareMetadata(before, after) {
const a = fields(before), b = fields(after);
const order = name => name.startsWith('GPS /') ? 0 : name.startsWith('Camera /') ? 1 : name.startsWith('Color Profile /') ? 3 : 2;
return [...new Set([...a.keys(), ...b.keys()])].sort((x, y) => order(x) - order(y) || x.localeCompare(y)).map(name => ({
name,
before: !a.has(name) && before?.scanError ? 'Unknown' : displayValue(a.get(name)),
after: !b.has(name) && after?.scanError ? 'Unknown' : displayValue(b.get(name)),
status: !b.has(name) ? (after?.scanError ? 'Unverified' : 'Not detected afterward')
: !a.has(name) ? (before?.scanError ? 'Unverified' : 'Output field')
: displayValue(a.get(name)) === displayValue(b.get(name)) ? 'Still present' : 'Changed',
}));
}
export function locationSummary(metadata) {
const gps = metadata?.GPS;
if (!gps || !Object.keys(gps).length) return null;
const coordinate = (decimal, dms, ref) => {
if (typeof decimal === 'number' && Number.isFinite(decimal)) return decimal.toFixed(6);
if (Array.isArray(dms) && dms.length === 3 && dms.every(Number.isFinite)) {
const value = dms[0] + dms[1] / 60 + dms[2] / 3600;
return (value * (/^[SW]$/i.test(ref) ? -1 : 1)).toFixed(6);
}
return null;
};
const lat = coordinate(gps.latitude, gps.GPSLatitude, gps.GPSLatitudeRef);
const lon = coordinate(gps.longitude, gps.GPSLongitude, gps.GPSLongitudeRef);
return lat !== null && lon !== null ? `Coordinates: ${lat}, ${lon}` : `${Object.keys(gps).length} GPS fields detected. See raw metadata for details.`;
}
+22 -50
View File
@@ -18,7 +18,7 @@ export const ACCEPTED_TYPES = [
export const ACCEPTED_EXTENSIONS = ".jpg,.jpeg,.png,.tiff,.tif,.heic,.avif";
/** Sections that describe the result rather than holding metadata values. */
const META_KEYS = ["fileType", "hasMetadata", "message"];
const META_KEYS = ["fileType", "hasMetadata", "message", "scanError"];
const GPS_FIELDS = [
"latitude",
@@ -159,7 +159,8 @@ const AI_VALUE_KEYWORDS = [
];
export function isAccepted(file) {
return Boolean(file) && ACCEPTED_TYPES.includes(file.type);
return Boolean(file) && (ACCEPTED_TYPES.includes(file.type) ||
((!file.type || file.type === "application/octet-stream") && /\.(jpe?g|png|tiff?|heic|avif)$/i.test(file.name)));
}
function pick(source, fields) {
@@ -178,7 +179,8 @@ export async function extractMetadata(file) {
try {
const { parse } = await import("exifr");
const exifData = await parse(file, {
let exifData, scanError;
try { exifData = await parse(file, {
tiff: true,
exif: true,
gps: true,
@@ -190,6 +192,9 @@ export async function extractMetadata(file) {
iptc: true,
icc: true,
ihdr: true,
text: true,
itxt: true,
ztxt: true,
jfif: true,
chunked: true,
firstChunkSize: 131072,
@@ -223,49 +228,22 @@ export async function extractMetadata(file) {
skip: [],
});
const buffer = await file.arrayBuffer();
} catch { scanError = 'Some metadata could not be parsed. Results may be incomplete.'; }
const buffer = await file.slice(0, 1024 * 1024).arrayBuffer();
const rawMetadata = await extractRawMetadata(
new Uint8Array(buffer),
file.type
);
let allMetadata = { ...(exifData || {}), ...rawMetadata };
if (file.type.includes("png") && Object.keys(allMetadata).length === 0) {
try {
const pngMetadata = await parse(file, {
ihdr: true,
itxt: true,
text: true,
ztxt: true,
time: true,
phys: true,
splt: true,
hist: true,
chrm: true,
gama: true,
iccp: true,
sbit: true,
srgb: true,
bkgd: true,
trns: true,
mergeOutput: false,
translateKeys: false,
sanitize: false,
});
if (pngMetadata && Object.keys(pngMetadata).length > 0) {
allMetadata = { ...allMetadata, ...pngMetadata };
}
} catch {
/* PNG-specific parsing is best effort */
}
}
const allMetadata = { ...(exifData || {}), ...rawMetadata };
if (Object.keys(allMetadata).length === 0) {
return cleanResult(file);
return cleanResult(file, scanError);
}
return categorise(allMetadata, rawMetadata);
const result = categorise(allMetadata, rawMetadata);
if (scanError) result.scanError = scanError;
return result;
} catch (error) {
return cleanResult(file, error);
}
@@ -274,23 +252,12 @@ export async function extractMetadata(file) {
function cleanResult(file, error) {
const isPng = file.type.includes("png");
const isJpeg = file.type.includes("jpeg") || file.type.includes("jpg");
let message;
if (isPng) {
message = "PNG files rarely carry EXIF metadata.";
} else if (
error &&
(error.message?.includes("not supported") ||
error.message?.includes("invalid"))
) {
message = "No EXIF data found in this image.";
} else {
message = "No metadata found. This image is already clean.";
}
const message = error ? 'Metadata could not be fully read. Results may be incomplete.' : 'No recognized metadata found. This is not proof that none exists.';
return {
fileType: isPng ? "png" : isJpeg ? "jpeg" : "other",
hasMetadata: false,
...(error ? { scanError: message } : {}),
message,
};
}
@@ -300,6 +267,7 @@ function categorise(allMetadata, rawMetadata) {
const keys = Object.keys(allMetadata);
const c2paFields = keys.filter((key) =>
!(key in rawMetadata) && !key.toLowerCase().includes('profile') &&
C2PA_KEY_HINTS.some((hint) => key.toLowerCase().includes(hint))
);
assign(metadata, "C2PA/AI Provenance", pick(allMetadata, c2paFields));
@@ -397,6 +365,10 @@ function categorise(allMetadata, rawMetadata) {
};
}
const assigned = new Set(Object.values(metadata).flatMap(values => Object.keys(values)));
const remaining = keys.filter(key => !assigned.has(key) && !(key in rawMetadata));
if (remaining.length) metadata.Other = { ...(metadata.Other || {}), ...pick(allMetadata, remaining) };
return metadata;
}
+17
View File
@@ -0,0 +1,17 @@
// Each task owns a worker. Termination stops decode/encode and suppresses stale results.
export function processImage(task, file, options = {}, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) { reject(new DOMException('Cancelled', 'AbortError')); return; }
const worker = new Worker(new URL('./image-worker.js', import.meta.url), { type: 'module' });
const finish = (callback, value) => {
worker.terminate();
signal?.removeEventListener('abort', cancel);
callback(value);
};
const cancel = () => finish(reject, new DOMException('Cancelled', 'AbortError'));
signal?.addEventListener('abort', cancel, { once: true });
worker.onmessage = ({ data }) => data.error ? finish(reject, new Error(data.error)) : finish(resolve, data.value);
worker.onerror = event => { event.preventDefault(); finish(reject, new Error(event.message || 'Image processing failed.')); };
worker.postMessage({ task, file, options });
});
}
+35
View File
@@ -0,0 +1,35 @@
export const THEMES = [
{ id: 'light', name: 'Light', description: 'Clean white, blue accents', scheme: 'light' },
{ id: 'dark', name: 'Dark', description: 'Charcoal, soft periwinkle', scheme: 'dark' },
{ id: 'graphite', name: 'Graphite', description: 'Neutral charcoal, silver', scheme: 'dark', replaces: 'forest' },
{ id: 'paper', name: 'Paper', description: 'Warm paper, dark olive ink', scheme: 'light', replaces: 'sand' },
{ id: 'cobalt', name: 'Cobalt', description: 'Navy frame, crisp blue', scheme: 'light', replaces: 'midnight' },
{ id: 'ember', name: 'Ember', description: 'Carbon black, warm orange', scheme: 'dark', replaces: 'plum' },
{ id: 'one-dark', name: 'One Dark', description: 'Slate grey, signature blue', scheme: 'dark', editor: true },
{ id: 'dracula', name: 'Dracula', description: 'Purple charcoal, lavender', scheme: 'dark', editor: true },
{ id: 'tokyo-night', name: 'Tokyo Night', description: 'Inky blue, electric periwinkle', scheme: 'dark', editor: true },
{ id: 'night-owl', name: 'Night Owl', description: 'Deep ocean, mint accents', scheme: 'dark', editor: true },
{ id: 'github-dark', name: 'GitHub Dark', description: 'Cool graphite, GitHub green', scheme: 'dark', editor: true },
{ id: 'github-light', name: 'GitHub Light', description: 'Porcelain, forest green', scheme: 'light', editor: true },
{ id: 'catppuccin-mocha', name: 'Catppuccin Mocha', description: 'Soft charcoal, pastel mauve', scheme: 'dark', editor: true },
{ id: 'catppuccin-latte', name: 'Catppuccin Latte', description: 'Creamy grey, violet accents', scheme: 'light', editor: true },
];
// Standalone so Vite can inline the same startup logic before the first paint.
export function bootstrapTheme(themes) {
let choice = 'system';
try {
const saved = localStorage.getItem('untrace-theme');
const match = themes.find(item => item.id === saved || item.replaces === saved);
if (saved === 'system') choice = saved;
else if (match) {
choice = match.id;
if (saved !== choice) localStorage.setItem('untrace-theme', choice);
}
} catch { /* Storage may be unavailable. */ }
const resolved = choice === 'system'
? (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark')
: choice;
document.documentElement.setAttribute('data-theme', resolved);
return choice;
}
+30 -49
View File
@@ -1,62 +1,43 @@
import { writable } from "svelte/store";
import { writable } from 'svelte/store';
import { THEMES, bootstrapTheme } from './theme-catalog.js';
const STORAGE_KEY = "untrace-theme";
/**
* The design system is dark by default and opts into light with
* data-theme="light" on <html>. A saved choice always wins; otherwise the
* operating system decides, falling back to dark.
*/
function preferredTheme() {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved === "light" || saved === "dark") return saved;
} catch {
/* storage can be blocked — fall through to the media query */
}
return window.matchMedia("(prefers-color-scheme: light)").matches
? "light"
: "dark";
}
function apply(theme) {
if (theme === "light") {
document.documentElement.setAttribute("data-theme", "light");
} else {
document.documentElement.removeAttribute("data-theme");
}
}
export { THEMES };
const STORAGE_KEY = 'untrace-theme'; // Keep existing portable preferences.
const valid = choice => choice === 'system' || THEMES.some(item => item.id === choice);
function createTheme() {
const { subscribe, set } = writable("dark");
const { subscribe, set } = writable('system');
let current = 'system';
let media;
function apply(choice) {
current = valid(choice) ? choice : 'system';
const resolved = current === 'system'
? ((media || matchMedia('(prefers-color-scheme: light)')).matches ? 'light' : 'dark')
: current;
document.documentElement.setAttribute('data-theme', resolved);
set(current);
}
function select(choice) {
apply(choice);
try { localStorage.setItem(STORAGE_KEY, current); } catch { /* Keep session choice. */ }
}
return {
subscribe,
init() {
const theme = preferredTheme();
apply(theme);
set(theme);
},
toggle() {
const next =
document.documentElement.getAttribute("data-theme") === "light"
? "dark"
: "light";
apply(next);
set(next);
try {
localStorage.setItem(STORAGE_KEY, next);
} catch {
/* a blocked store just means the choice is not remembered */
}
media = matchMedia('(prefers-color-scheme: light)');
apply(bootstrapTheme(THEMES));
const onSystemChange = () => { if (current === 'system') apply('system'); };
media.addEventListener('change', onSystemChange);
return () => media.removeEventListener('change', onSystemChange);
},
select,
// Retained for the unused upstream toggle component.
toggle() { select(document.documentElement.dataset.theme === 'light' ? 'dark' : 'light'); },
};
}
export const theme = createTheme();
/** Duration helper so Svelte transitions honour prefers-reduced-motion. */
export function motionSafe(ms) {
if (typeof window === "undefined") return ms;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : ms;
if (typeof window === 'undefined') return ms;
return window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : ms;
}
+254
View File
@@ -0,0 +1,254 @@
/* Local theme palettes override existing semantic tokens. The upstream
tokens.css remains untouched. No component-specific colour values. */
[data-theme='dark'] {
--bg: #080808;
--surface: #121216;
--elevated: #1a1a20;
--text-1: #f4f4f6;
--text-2: #a9a9b4;
--text-3: #83838f;
--accent: #8fa8ff;
--accent-fg: #080808;
--border: #26262e;
--border-strong: #3a3a46;
--success: #3ecf8e;
--warning: #e8b23d;
--error: #f26d6d;
--info: #62b0f5;
color-scheme: dark;
}
/* Neutral surfaces keep photographs visually separate from the theme. */
[data-theme='graphite'] {
--bg: #18191b;
--surface: #232528;
--elevated: #303338;
--text-1: #f4f4f2;
--text-2: #c8c9cb;
--text-3: #a5a8ac;
--accent: #ecece9;
--accent-fg: #18191b;
--border: #3b3e43;
--border-strong: #656a72;
--success: #8bd4b0;
--warning: #e9c58c;
--error: #f49e9e;
--info: #acc7ec;
color-scheme: dark;
}
[data-theme='paper'] {
--bg: #e9e6df;
--surface: #faf9f6;
--elevated: #ffffff;
--text-1: #242521;
--text-2: #4d5048;
--text-3: #62655c;
--accent: #343c32;
--accent-fg: #faf9f6;
--border: #d0d0c7;
--border-strong: #959b8b;
--success: #326446;
--warning: #805b12;
--error: #a23a37;
--info: #3f5c85;
--shadow-sm: none;
--shadow-md: none;
--shadow-lg: none;
color-scheme: light;
}
[data-theme='cobalt'] {
--bg: #e3e8f0;
--surface: #f9fbff;
--elevated: #ffffff;
--text-1: #162132;
--text-2: #3e4e63;
--text-3: #566579;
--accent: #244ddb;
--accent-fg: #ffffff;
--border: #c9d2e0;
--border-strong: #8698b2;
--success: #226948;
--warning: #805909;
--error: #b03742;
--info: #285abc;
--shadow-sm: none;
--shadow-md: none;
--shadow-lg: none;
color-scheme: light;
}
/* Cobalt has a dark frame and a light working area. The same roles are
overridden locally, so controls keep their shared component styles. */
html[data-theme='cobalt'] :is(.app-header, .app-rail),
.theme-mini[data-theme='cobalt'] > :is(.mini-header, .mini-rail) {
--bg: #273a5d;
--surface: #15243e;
--elevated: #304564;
--text-1: #f2f5fb;
--text-2: #c6d1e5;
--text-3: #a2b4ce;
--border: #344564;
--border-strong: #61799d;
--accent: #9ab5ff;
--accent-fg: #15243e;
color-scheme: dark;
}
[data-theme='ember'] {
--bg: #101010;
--surface: #1d1d1d;
--elevated: #2c2c2c;
--text-1: #f5f3ef;
--text-2: #cdc9c3;
--text-3: #a7a39d;
--accent: #ffa168;
--accent-fg: #21150d;
--border: #363432;
--border-strong: #71685e;
--success: #9ed0a2;
--warning: #ebce87;
--error: #ffa1a1;
--info: #a4c5ed;
color-scheme: dark;
}
/* Editor-inspired adaptations; sources and design notes: THEME-CREDITS.md. */
[data-theme='one-dark'] {
--bg: #21252b;
--surface: #282c34;
--elevated: #343a45;
--text-1: #abb2bf;
--text-2: #abb2bf;
--text-3: #a4acba;
--accent: #61afef;
--accent-fg: #18212b;
--border: #3e4451;
--border-strong: #687386;
--success: #98c379;
--warning: #e5c07b;
--error: #e06c75;
--info: #61afef;
color-scheme: dark;
}
[data-theme='dracula'] {
--bg: #21222c;
--surface: #282a36;
--elevated: #343746;
--text-1: #f8f8f2;
--text-2: #d6d6e5;
--text-3: #aaaec9;
--accent: #bd93f9;
--accent-fg: #282a36;
--border: #44475a;
--border-strong: #727997;
--success: #50fa7b;
--warning: #f1fa8c;
--error: #ff5555;
--info: #8be9fd;
color-scheme: dark;
}
[data-theme='tokyo-night'] {
--bg: #16161e;
--surface: #1a1b26;
--elevated: #24283b;
--text-1: #c0caf5;
--text-2: #a9b1d6;
--text-3: #939dc2;
--accent: #7aa2f7;
--accent-fg: #16161e;
--border: #343b58;
--border-strong: #636f98;
--success: #9ece6a;
--warning: #e0af68;
--error: #f7768e;
--info: #7dcfff;
color-scheme: dark;
}
[data-theme='night-owl'] {
--bg: #011627;
--surface: #071e30;
--elevated: #112b40;
--text-1: #d6deeb;
--text-2: #b6c8d9;
--text-3: #8fa8be;
--accent: #7fdbca;
--accent-fg: #011627;
--border: #234157;
--border-strong: #53748d;
--success: #addb67;
--warning: #ecc48d;
--error: #ef8080;
--info: #82aaff;
color-scheme: dark;
}
[data-theme='github-dark'] {
--bg: #0d1117;
--surface: #161b22;
--elevated: #21262d;
--text-1: #e6edf3;
--text-2: #b1bac4;
--text-3: #919ba6;
--accent: #3fb950;
--accent-fg: #0d1117;
--border: #30363d;
--border-strong: #687584;
--success: #56d364;
--warning: #e3b341;
--error: #ff7b72;
--info: #79c0ff;
color-scheme: dark;
}
[data-theme='github-light'] {
--bg: #f6f8fa;
--surface: #ffffff;
--elevated: #ffffff;
--text-1: #1f2328;
--text-2: #424a53;
--text-3: #59636e;
--accent: #1a7f37;
--accent-fg: #ffffff;
--border: #d0d7de;
--border-strong: #8c959f;
--success: #1a7f37;
--warning: #7d4e00;
--error: #cf222e;
--info: #0969da;
--shadow-sm: none;
--shadow-md: none;
--shadow-lg: none;
color-scheme: light;
}
[data-theme='catppuccin-mocha'] {
--bg: #181825;
--surface: #1e1e2e;
--elevated: #313244;
--text-1: #cdd6f4;
--text-2: #bac2de;
--text-3: #a6adc8;
--accent: #cba6f7;
--accent-fg: #1e1e2e;
--border: #45475a;
--border-strong: #6c7086;
--success: #a6e3a1;
--warning: #f9e2af;
--error: #f38ba8;
--info: #89b4fa;
color-scheme: dark;
}
[data-theme='catppuccin-latte'] {
--bg: #e6e9ef;
--surface: #eff1f5;
--elevated: #ffffff;
--text-1: #4c4f69;
--text-2: #5c5f77;
--text-3: #62657a;
--accent: #8839ef;
--accent-fg: #ffffff;
--border: #bcc0cc;
--border-strong: #8c8fa1;
--success: #33751b;
--warning: #865900;
--error: #d20f39;
--info: #1e66f5;
--shadow-sm: none;
--shadow-md: none;
--shadow-lg: none;
color-scheme: light;
}