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:
1 parent
83a4d3f2c4
commit
00991c1608
40 files changed
+3577
-3697
No files matched your search
@@ -0,0 +1,256 @@
|
||||
<script>
|
||||
import { createEventDispatcher, onDestroy } from "svelte";
|
||||
import { fade } from "svelte/transition";
|
||||
import { extractMetadata } from "./metadata.js";
|
||||
import { cleanImage } from "./clean.js";
|
||||
import { motionSafe } from "./theme.js";
|
||||
import Dropzone from "./Dropzone.svelte";
|
||||
import ReviewPanel from "./ReviewPanel.svelte";
|
||||
import ResultPanel from "./ResultPanel.svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const steps = ["Scan", "Review", "Download"];
|
||||
|
||||
/** @type {'idle'|'scanning'|'review'|'cleaning'|'done'} */
|
||||
let stage = "idle";
|
||||
let file = null;
|
||||
let metadata = null;
|
||||
let previewUrl = null;
|
||||
let result = null;
|
||||
|
||||
$: current = stage === "idle" || stage === "scanning" ? 0 : stage === "done" ? 2 : 1;
|
||||
$: busy = stage === "scanning" || stage === "cleaning";
|
||||
|
||||
function stepState(index, active) {
|
||||
if (index < active) return "done";
|
||||
if (index === active) return "current";
|
||||
return "upcoming";
|
||||
}
|
||||
|
||||
function release() {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
if (result?.url) URL.revokeObjectURL(result.url);
|
||||
previewUrl = null;
|
||||
result = null;
|
||||
}
|
||||
|
||||
async function onSelect(event) {
|
||||
release();
|
||||
file = event.detail.file;
|
||||
previewUrl = URL.createObjectURL(file);
|
||||
metadata = null;
|
||||
stage = "scanning";
|
||||
|
||||
try {
|
||||
metadata = await extractMetadata(file);
|
||||
stage = "review";
|
||||
} catch (error) {
|
||||
console.error("Scan failed:", error);
|
||||
stage = "idle";
|
||||
dispatch("notify", {
|
||||
tone: "err",
|
||||
message: "That image could not be read. Try a different file.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function clean() {
|
||||
if (!file) return;
|
||||
stage = "cleaning";
|
||||
result = await cleanImage(file);
|
||||
stage = "done";
|
||||
|
||||
if (result.fallback) {
|
||||
dispatch("notify", {
|
||||
tone: "err",
|
||||
message:
|
||||
"This image could not be re-encoded, so nothing was removed. Try converting it to JPEG or PNG first.",
|
||||
});
|
||||
} else {
|
||||
dispatch("notify", { tone: "ok", message: "Metadata removed." });
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
release();
|
||||
file = null;
|
||||
metadata = null;
|
||||
stage = "idle";
|
||||
}
|
||||
|
||||
onDestroy(release);
|
||||
</script>
|
||||
|
||||
<section class="card tool" id="tool" aria-labelledby="tool-heading">
|
||||
<h2 class="visually-hidden" id="tool-heading">Clean an image</h2>
|
||||
|
||||
<ol class="stepper">
|
||||
{#each steps as label, index}
|
||||
<li class="step" data-state={stepState(index, current)}>
|
||||
<span class="num" aria-hidden="true">
|
||||
{#if stepState(index, current) === "done"}
|
||||
<Icon name="check" size="sm" />
|
||||
{:else}
|
||||
{index + 1}
|
||||
{/if}
|
||||
</span>
|
||||
<span class="label">{label}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
<div class="panel" aria-live="polite">
|
||||
{#if stage === "idle"}
|
||||
<div in:fade={{ duration: motionSafe(200) }}>
|
||||
<Dropzone
|
||||
{busy}
|
||||
on:select={onSelect}
|
||||
on:reject={(e) => dispatch("notify", { tone: "err", message: e.detail.message })}
|
||||
/>
|
||||
</div>
|
||||
{:else if stage === "scanning"}
|
||||
<div class="loading" in:fade={{ duration: motionSafe(200) }}>
|
||||
<div class="skeleton block"></div>
|
||||
<div class="lines">
|
||||
<div class="skeleton line"></div>
|
||||
<div class="skeleton line short"></div>
|
||||
<div class="skeleton line"></div>
|
||||
</div>
|
||||
<p class="loading-text">Reading metadata from {file.name}…</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div in:fade={{ duration: motionSafe(200) }}>
|
||||
{#if stage === "done" && result}
|
||||
<ResultPanel {result} {previewUrl} />
|
||||
{:else}
|
||||
<ReviewPanel {file} {metadata} {previewUrl} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if stage !== "idle" && stage !== "scanning"}
|
||||
<div class="action-bar">
|
||||
{#if stage === "done" && result}
|
||||
{#if result.fallback}
|
||||
<button type="button" class="btn btn-primary btn-block" on:click={reset}>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Try another image
|
||||
</button>
|
||||
{:else}
|
||||
<a
|
||||
class="btn btn-primary btn-block"
|
||||
href={result.url}
|
||||
download={result.filename}
|
||||
>
|
||||
<Icon name="download" size="sm" />
|
||||
Download clean image
|
||||
</a>
|
||||
<button type="button" class="btn btn-secondary btn-block" on:click={reset}>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Clean another image
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-block"
|
||||
aria-disabled={stage === "cleaning"}
|
||||
on:click={clean}
|
||||
>
|
||||
{#if stage === "cleaning"}
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
Cleaning…
|
||||
{:else}
|
||||
<Icon name="shield" size="sm" />
|
||||
Clean image
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-block"
|
||||
disabled={stage === "cleaning"}
|
||||
on:click={reset}
|
||||
>
|
||||
Choose a different image
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.tool {
|
||||
padding: var(--space-4);
|
||||
scroll-margin-top: calc(var(--topbar-h) + var(--space-4));
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.tool {
|
||||
padding: var(--space-5);
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-height: calc(var(--space-9) * 2);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.loading {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.block {
|
||||
height: calc(var(--space-9) * 2);
|
||||
}
|
||||
|
||||
.lines {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.line {
|
||||
height: var(--space-5);
|
||||
}
|
||||
|
||||
.line.short {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-2);
|
||||
line-height: var(--lh-2);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* On small screens the bar sticks to the bottom of the viewport so the one
|
||||
primary action is always within thumb reach; it becomes an inline row at
|
||||
the tablet breakpoint. */
|
||||
.action-bar {
|
||||
margin-top: var(--space-5);
|
||||
margin-bottom: calc(var(--space-4) * -1);
|
||||
border-bottom-left-radius: var(--radius-md);
|
||||
border-bottom-right-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.action-bar {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user