Publish Untrace Desktop 1.6.1 with offline batch workflows
This commit is contained in:
1 parent
99b4b22352
commit
7ce81b0727
47 files changed
+5958
-1208
No files matched your search
+5
-123
@@ -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
@@ -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)); }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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} />
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -57,7 +57,7 @@
|
||||
}
|
||||
|
||||
async function clean() {
|
||||
if (!file) return;
|
||||
if (!file || stage === "cleaning") return;
|
||||
stage = "cleaning";
|
||||
result = await cleanImage(file);
|
||||
stage = "done";
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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. */ }
|
||||
}
|
||||
@@ -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.' });
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user