Rebuild on the RealBrain design system and retarget SEO to content credentials

Search Console (Jun 4 – Sep 3) shows the page ranking on the first results
page for "content credentials" queries while converting badly: "ai
credentials remover" at 113 impressions and 0.88% CTR from position 6.7,
"remove content credentials from image online" at 2.15% from position 5.0,
"remove ai content credentials from image" at 0% from position 7.8. The
title led with "EXIF metadata", a phrase that appears in none of the top
queries, so the snippet never matched the intent behind the impressions.

SEO
- Title, description and H1 rebuilt around "remove content credentials from
  an image"; EXIF kept as a secondary term.
- Added a how-to section and a nine-question FAQ answering the question
  queries that had no content behind them, including the LinkedIn AI label
  and the Instagram AI tag.
- Structured data as an @graph: WebSite (alternateName "Untrace AI" for the
  unserved brand query), Organization, WebApplication, HowTo, FAQPage.
- Fixed sitemap.xml, which pointed at /untrace/ — a URL that does not exist.
- Regenerated the Open Graph image to match the new design.

Design
- Vendored the design system at src/tokens.css and composed the app from it.
  The old styling broke most of its rules: gradients, font weights 800 and
  900, raw #2563eb, Inter, and emoji as the only status signal.
- Reworked the tool into a Scan / Review / Download flow that lists what it
  found before changing anything, with the primary action pinned within
  thumb reach on small screens.
- Dark by default with a light theme, applied before first paint so the page
  never flashes the wrong surface.
- Replaced the floating donation widget, which painted an off-token circle
  over the page and covered the mobile action bar, with a footer link.

Structure
- Moved detection into src/lib/metadata.js and removal into src/lib/clean.js;
  the canvas callbacks are now a promise chain.
- Dropped Tailwind and PostCSS. Both were configured twice, and no Tailwind
  utility was used. Stylesheet is 6.2 kB gzipped with the whole system in it.

Fixes
- The stepper never advanced: stepState(index) read `current` from the
  closure, which Svelte 4 cannot track as a template dependency.
- The cleaner's fallback returned the original, uncleaned file while the UI
  reported "All EXIF metadata removed". It now reports the failure instead.

Verified against a JPEG carrying EXIF, GPS, XMP and an APP11 JUMBF C2PA
manifest: the output retains only APP0 and the sRGB ICC profile, with every
provenance marker gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pRSdR57Ze9M2meDfYZgYE
This commit is contained in:
SharifandClaude Opus 5 committed 2026-09-05 23:03:00 +06:00
1 parent 83a4d3f2c4
commit 00991c1608
40 files changed
+3577 -3697

No files matched your search

+92 -301
View File
@@ -1,335 +1,126 @@
<script>
import { onMount } from "svelte";
import ImageUploader from "./lib/ImageUploader.svelte";
import MetadataViewer from "./lib/MetadataViewer.svelte";
import ImageProcessor from "./lib/ImageProcessor.svelte";
import ThemeToggle from "./lib/ThemeToggle.svelte";
import Header from "./lib/Header.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 PrivacyInfo from "./lib/PrivacyInfo.svelte";
import Toast from "./lib/Toast.svelte";
let selectedFile = null;
let metadata = null;
let processedImage = null;
let isProcessing = false;
let showMetadata = false;
let showFileInfo = false;
let showPrivacyInfo = false;
let darkMode = false;
let toasts = [];
let nextId = 0;
onMount(() => {
const savedTheme = localStorage.getItem("theme");
if (savedTheme) {
darkMode = savedTheme === "dark";
} else {
darkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
}
updateTheme();
});
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 updateTheme() {
if (darkMode) {
document.documentElement.setAttribute("data-theme", "dark");
} else {
document.documentElement.removeAttribute("data-theme");
}
localStorage.setItem("theme", darkMode ? "dark" : "light");
function notify(event) {
const id = nextId++;
toasts = [...toasts, { id, ...event.detail }];
setTimeout(() => dismiss(id), 5000);
}
function handleThemeToggle() {
darkMode = !darkMode;
updateTheme();
function dismiss(id) {
toasts = toasts.filter((toast) => toast.id !== id);
}
function handleFileSelected(event) {
selectedFile = event.detail.file;
metadata = event.detail.metadata;
processedImage = null;
showMetadata = false;
}
function handleProcessingStart() {
isProcessing = true;
}
function handleProcessingComplete(event) {
isProcessing = false;
processedImage = event.detail.processedImage;
}
function handleReset() {
selectedFile = null;
metadata = null;
processedImage = null;
isProcessing = false;
showMetadata = false;
}
function toggleMetadata() {
showMetadata = !showMetadata;
}
function toggleFileInfo() {
showFileInfo = !showFileInfo;
}
function togglePrivacyInfo() {
showPrivacyInfo = !showPrivacyInfo;
}
onMount(() => theme.init());
</script>
<ThemeToggle {darkMode} on:toggle={handleThemeToggle} />
<TopBar />
<main class="container py-8">
<Header />
<div class="grid gap-6">
<section class="card">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">Upload Your Image</h2>
<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>
<ImageUploader
on:fileSelected={handleFileSelected}
on:reset={handleReset}
disabled={isProcessing}
/>
</section>
<!-- Processing Section -->
{#if selectedFile}
<section class="grid grid-2 gap-6">
<div class="card">
<div class="flex justify-between items-center mb-3">
<h3 class="text-lg font-semibold">Original</h3>
{#if metadata}
<button class="btn btn-ghost text-sm" on:click={toggleMetadata}>
{showMetadata ? "Hide" : "Show"} Metadata
</button>
{/if}
</div>
<Tool on:notify={notify} />
<div class="text-center mb-3">
<img
src={URL.createObjectURL(selectedFile)}
alt="Original"
class="image-preview"
/>
</div>
<ul class="assurances">
{#each assurances as item}
<li>
<h2>{item.title}</h2>
<p>{item.detail}</p>
</li>
{/each}
</ul>
<div class="text-xs text-secondary">
<p>
{selectedFile.name} • {(selectedFile.size / 1024).toFixed(1)} KB
</p>
{#if metadata && metadata.message && !metadata.hasMetadata}
<p class="text-success">✓ No metadata detected</p>
{:else if metadata}
<p class="text-warning">
⚠️ {Object.keys(metadata).filter(
(key) => !["fileType", "hasMetadata", "message"].includes(key)
).length} metadata entries found
</p>
{/if}
</div>
{#if showMetadata && metadata}
<div class="mt-4">
<MetadataViewer {metadata} />
</div>
{/if}
</div>
<div class="card">
<h3 class="text-lg font-semibold mb-3">Clean Metadata</h3>
<ImageProcessor
file={selectedFile}
on:processingStart={handleProcessingStart}
on:processingComplete={handleProcessingComplete}
disabled={isProcessing}
/>
{#if isProcessing}
<div class="mt-3">
<div class="progress-bar">
<div class="progress-fill" style="width: 100%;"></div>
</div>
<p class="text-xs text-secondary mt-1">Processing...</p>
</div>
{/if}
{#if processedImage}
<div class="mt-4 slide-up">
<h4 class="font-medium mb-2">✅ Clean Image</h4>
<div class="text-center mb-3">
<img
src={processedImage.url}
alt="Cleaned"
class="image-preview"
/>
</div>
<div class="text-xs text-secondary mb-3">
<p>
{(processedImage.size / 1024).toFixed(1)} KB •
{processedImage.compressionRatio
? `${processedImage.compressionRatio > 0 ? "-" : "+"}${Math.abs(processedImage.compressionRatio)}% size`
: "Processed"}
</p>
{#if processedImage.wasSecondPass}
<p class="status-warning">
⚠️ Used enhanced cleaning (second pass)
</p>
{/if}
<p class="status-success">✓ All EXIF metadata removed</p>
<p class="status-success">✓ GPS location data cleared</p>
<p class="status-success">✓ AI content credentials stripped</p>
<p class="status-success">✓ C2PA provenance data removed</p>
<p class="status-success">✓ Camera & device info cleared</p>
</div>
<a
href={processedImage.url}
download={processedImage.filename}
class="btn btn-success btn-lg w-full"
>
<svg
width="20"
height="20"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
Download Cleaned Image
</a>
</div>
{/if}
</div>
</section>
{/if}
<section class="card">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">🔐 Privacy & Security</h2>
<button
class="btn btn-ghost text-sm"
on:click={togglePrivacyInfo}
type="button"
>
{showPrivacyInfo ? "Hide" : "Show"} Details
</button>
</div>
<div class="mb-3">
<p class="text-sm text-secondary">
<span class="text-success font-semibold">✓</span> 100% client-side
processing
<span class="mx-2">•</span>
<span class="text-success font-semibold">✓</span> No data collection
<span class="mx-2">•</span>
<span class="text-success font-semibold">✓</span> Open source
</p>
</div>
{#if showPrivacyInfo}
<PrivacyInfo />
{/if}
</section>
<div class="stack sections">
<Removals />
<HowItWorks />
<Faq />
</div>
</div>
</main>
<Footer />
<Toast {toasts} {dismiss} />
<style>
.w-full {
width: 100%;
main {
padding-bottom: var(--space-8);
}
.status-warning {
color: var(--warning-color, #f59e0b);
}
.image-preview {
max-width: 100%;
max-height: 200px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.progress-bar {
width: 100%;
height: 4px;
background-color: var(--border-color);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background-color: var(--primary-color);
animation: pulse 1s ease-in-out infinite;
}
.slide-up {
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.btn-ghost {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.btn-ghost:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
.grid {
.assurances {
display: grid;
gap: 1.5rem;
gap: var(--space-3);
margin-top: var(--space-5);
list-style: none;
}
.grid-2 {
grid-template-columns: 1fr 1fr;
}
@media (max-width: 768px) {
.grid-2 {
grid-template-columns: 1fr;
}
.image-preview {
max-height: 150px;
@media (min-width: 768px) {
.assurances {
grid-template-columns: repeat(3, 1fr);
gap: var(--space-4);
}
}
.text-secondary {
color: var(--text-secondary);
.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>