feat(ui): squirrel logo, fork ribbon, clickable steps, and polish

- Squirrel engineer logo (SkwikLogo.vue) with hard hat and ruler
- Matching favicon with squirrel head silhouette
- Gitea fork ribbon (top-left, desktop only, Gitea green)
- Centered header with logo, title, and subtitle
- Footer: "Made by Samuel Prevost" with GitHub link
- Clickable step indicators for previously visited steps
- Smaller datum dots (6/4 base radius with visual cap)
- Engineering-tool styling: monospace for measurements, Geist Mono
  font, deeper dark mode colors, instrument-panel header
- EXIF viewer explains why focal length matters
- Upload page describes what Skwik does

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Samuel Prevost 2026-04-14 23:19:44 +02:00
parent 11e8013b6a
commit 98c6fc9a35
8 changed files with 503 additions and 34 deletions

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -6,20 +6,44 @@ import ExifViewer from "@/components/ExifViewer.vue"
import DatumEditor from "@/components/DatumEditor.vue"
import ResultViewer from "@/components/ResultViewer.vue"
import ThemeToggle from "@/components/ThemeToggle.vue"
import SkwikLogo from "@/components/SkwikLogo.vue"
const store = useAppStore()
</script>
<template>
<div class="min-h-screen bg-background text-foreground">
<!-- Gitea fork ribbon top-left, desktop only -->
<a
href="https://serv.e1n.sh/git/sam1902/skwik"
target="_blank"
rel="noopener"
class="github-fork-ribbon fixed left-0 top-0 z-[100] hidden md:block"
data-ribbon="Fork me on Gitea"
title="Fork me on Gitea"
>Fork me on Gitea</a
>
<header
class="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
>
<div
class="mx-auto flex h-14 max-w-7xl items-center justify-between px-4"
class="mx-auto grid h-14 max-w-7xl grid-cols-3 items-center px-4"
>
<h1 class="text-lg font-semibold tracking-tight">Skwik</h1>
<div class="flex items-center gap-4">
<div><!-- spacer for ribbon --></div>
<div class="flex items-center justify-center gap-2">
<SkwikLogo :size="28" />
<h1
class="font-mono text-lg font-semibold tracking-tight"
>
Skwik
</h1>
<span
class="hidden text-[10px] font-medium uppercase tracking-widest text-muted-foreground sm:inline"
>Perspective Correction</span
>
</div>
<div class="flex items-center justify-end gap-4">
<StepIndicator />
<ThemeToggle />
</div>
@ -32,5 +56,18 @@ const store = useAppStore()
<DatumEditor v-else-if="store.currentStep === 3" />
<ResultViewer v-else-if="store.currentStep === 4" />
</main>
<footer
class="border-t border-border/50 py-4 text-center text-xs text-muted-foreground"
>
Made by
<a
href="https://github.com/usr-ein"
target="_blank"
rel="noopener"
class="underline underline-offset-2 transition-colors hover:text-foreground"
>Samuel Prevost</a
>
</footer>
</div>
</template>

View File

@ -1,4 +1,5 @@
@import url("https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap");
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap');
@import "tailwindcss";
@ -9,7 +10,8 @@
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-sans: "Geist Variable", sans-serif;
--font-sans: "Geist", "Geist Variable", ui-sans-serif, sans-serif;
--font-mono: "Geist Mono", ui-monospace, monospace;
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
@ -84,10 +86,10 @@
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--background: oklch(0.13 0 0);
--foreground: oklch(0.95 0 0);
--card: oklch(0.175 0 0);
--card-foreground: oklch(0.95 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
@ -99,7 +101,7 @@
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
@ -126,3 +128,63 @@
@apply font-sans;
}
}
/* Fork-me ribbon — adapted from github-fork-ribbon-css */
.github-fork-ribbon {
width: 12.1em;
height: 12.1em;
overflow: hidden;
position: fixed;
top: 0;
left: 0;
z-index: 100;
pointer-events: none;
text-decoration: none;
text-indent: -999em;
font-size: 0.85em;
}
.github-fork-ribbon::before,
.github-fork-ribbon::after {
position: absolute;
display: block;
width: 15.38em;
height: 1.54em;
top: 3.23em;
right: 0.5em;
box-sizing: content-box;
transform: rotate(-45deg);
}
.github-fork-ribbon::before {
content: "";
padding: 0.38em 0;
background-color: #2d7a2e;
background-image: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.05),
rgba(0, 0, 0, 0.15)
);
box-shadow:
0 0.15em 0.23em 0 rgba(0, 0, 0, 0.5),
0 0 0 1px #5cb85c,
inset 0 1px 0 rgba(255, 255, 255, 0.2),
inset 0 -1px 0 rgba(0, 0, 0, 0.2);
border-top: 1px dashed rgba(255, 255, 255, 0.25);
border-bottom: 1px dashed rgba(255, 255, 255, 0.25);
pointer-events: auto;
}
.github-fork-ribbon::after {
content: attr(data-ribbon);
color: #fff;
font: 700 0.9em "Helvetica Neue", Helvetica, Arial, sans-serif;
line-height: 1.54em;
text-decoration: none;
text-align: center;
text-indent: 0;
text-shadow:
0 -1px 0 rgba(0, 0, 0, 0.5),
0 1px 2px rgba(0, 0, 0, 0.3);
pointer-events: auto;
}

View File

@ -49,19 +49,23 @@ function getPointConfigs(datum: Datum, dIdx: number) {
const color = getDatumColor(dIdx)
const isSelected = store.selectedDatumId === datum.id
const points = datum.type === "rectangle" ? datum.corners : datum.endpoints
const radius = isSelected ? 10 : 7
const baseRadius = isSelected ? 6 : 4
const visualRadius = Math.max(
baseRadius / scale.value,
baseRadius * 0.5
)
return points.map((pt, pIdx) => ({
x: pt.x,
y: pt.y,
radius: radius / scale.value,
radius: visualRadius,
fill: color,
stroke: isSelected ? "#fff" : color,
strokeWidth: 2 / scale.value,
strokeWidth: 1.5 / scale.value,
draggable: true,
_datumId: datum.id,
_pointIndex: pIdx,
hitStrokeWidth: 20 / scale.value,
hitStrokeWidth: 12 / scale.value,
}))
}

View File

@ -1,7 +1,9 @@
<script setup lang="ts">
import { ref, computed } from "vue"
import { ref, computed, watch } from "vue"
import { useMediaQuery } from "@vueuse/core"
import { useAppStore } from "@/stores/app"
import { saveDatums } from "@/lib/datum-cache"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Tooltip,
@ -40,6 +42,16 @@ const nextTooltip = computed(() => {
const names = incompleteDatums.value.map((d) => d.label)
return `Missing dimensions: ${names.join(", ")}`
})
watch(
() => store.datums,
(datums) => {
if (store.fileHash && datums.length > 0) {
saveDatums(store.fileHash, datums)
}
},
{ deep: true },
)
</script>
<template>
@ -77,6 +89,16 @@ const nextTooltip = computed(() => {
</div>
</div>
<Transition name="fade">
<Badge
v-if="store.cacheRestoreMessage"
variant="secondary"
class="text-xs"
>
{{ store.cacheRestoreMessage }}
</Badge>
</Transition>
<!-- Single layout: canvas always present, sidebar conditionally placed -->
<div
class="grid gap-4"

View File

@ -149,7 +149,9 @@ function getExifRows(): ExifRow[] {
<TableCell class="font-medium">{{
row.label
}}</TableCell>
<TableCell>{{ row.value }}</TableCell>
<TableCell class="font-mono text-sm">{{
row.value
}}</TableCell>
</TableRow>
</TableBody>
</Table>
@ -179,26 +181,31 @@ function getExifRows(): ExifRow[] {
</svg>
<div class="text-sm text-muted-foreground">
<p class="font-medium text-foreground">
Lens Correction Info
Why EXIF matters
</p>
<p class="mt-1">
Focal length and lens model data allow the
algorithm to estimate radial distortion
(barrel/pincushion) introduced by the lens.
Without this, the perspective correction relies
solely on the datum geometry you provide.
</p>
<p class="mt-2">
<template v-if="store.exifData.focalLength">
This image was shot at
<strong
>{{ store.exifData.focalLength }}mm</strong
Detected
<span class="font-mono font-medium text-foreground"
>{{ store.exifData.focalLength }}mm</span
>
<template v-if="store.exifData.lensModel">
with a
<strong>{{
on
<span class="font-medium text-foreground">{{
store.exifData.lensModel
}}</strong> </template
>. The deskew algorithm can use this to correct
barrel/pincushion distortion.
}}</span> </template
>. Lens correction will be applied.
</template>
<template v-else>
No focal length data found. The algorithm will
rely solely on datum measurements for
perspective correction.
No focal length found. Lens distortion
correction will be skipped.
</template>
</p>
</div>

View File

@ -0,0 +1,280 @@
<script setup lang="ts">
withDefaults(
defineProps<{
size?: number
}>(),
{
size: 28,
}
)
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 64 64"
:width="size"
:height="size"
aria-label="Skwik squirrel logo"
>
<!-- Tail (fluffy, curling up behind) -->
<path
d="M50 52 Q58 40 54 28 Q52 22 48 20
Q55 18 56 12 Q56 8 52 10
Q48 12 46 18"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
opacity="0.7"
/>
<!-- Body -->
<ellipse
cx="32"
cy="46"
rx="12"
ry="10"
fill="none"
stroke="currentColor"
stroke-width="2"
/>
<!-- Head -->
<ellipse
cx="32"
cy="28"
rx="12"
ry="13"
fill="none"
stroke="currentColor"
stroke-width="2"
/>
<!-- Ear tufts -->
<path
d="M22 18 Q18 10 14 12 Q12 16 20 20"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M42 18 Q46 10 50 12 Q52 16 44 20"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Eyes -->
<circle
cx="27"
cy="26"
r="3"
fill="currentColor"
/>
<circle
cx="37"
cy="26"
r="3"
fill="currentColor"
/>
<!-- Eye shine -->
<circle cx="28.2" cy="25" r="1" fill="var(--background, #fff)" />
<circle cx="38.2" cy="25" r="1" fill="var(--background, #fff)" />
<!-- Nose -->
<ellipse
cx="32"
cy="32"
rx="2"
ry="1.5"
fill="currentColor"
/>
<!-- Cheeks -->
<ellipse
cx="24"
cy="32"
rx="4"
ry="3"
fill="none"
stroke="currentColor"
stroke-width="1"
opacity="0.3"
/>
<ellipse
cx="40"
cy="32"
rx="4"
ry="3"
fill="none"
stroke="currentColor"
stroke-width="1"
opacity="0.3"
/>
<!-- Mouth -->
<path
d="M30 34 Q32 36 34 34"
fill="none"
stroke="currentColor"
stroke-width="1.2"
stroke-linecap="round"
/>
<!-- Arms holding ruler -->
<path
d="M20 42 L12 38"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
<path
d="M44 42 L52 38"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
<!-- Ruler (held diagonally) -->
<rect
x="4"
y="34"
width="22"
height="4"
rx="0.5"
fill="none"
stroke="currentColor"
stroke-width="1.5"
transform="rotate(-15 15 36)"
/>
<!-- Ruler tick marks -->
<line
x1="8" y1="34" x2="8" y2="36"
stroke="currentColor"
stroke-width="1"
transform="rotate(-15 15 36)"
/>
<line
x1="12" y1="34" x2="12" y2="36"
stroke="currentColor"
stroke-width="1"
transform="rotate(-15 15 36)"
/>
<line
x1="16" y1="34" x2="16" y2="36"
stroke="currentColor"
stroke-width="1"
transform="rotate(-15 15 36)"
/>
<line
x1="20" y1="34" x2="20" y2="36"
stroke="currentColor"
stroke-width="1"
transform="rotate(-15 15 36)"
/>
<!-- Safety goggles on forehead -->
<path
d="M22 20 Q27 17 32 18 Q37 17 42 20"
fill="none"
stroke="#f59e0b"
stroke-width="2"
stroke-linecap="round"
/>
<!-- Goggle lenses -->
<ellipse
cx="26"
cy="20"
rx="3.5"
ry="2.5"
fill="none"
stroke="#f59e0b"
stroke-width="1.5"
/>
<ellipse
cx="38"
cy="20"
rx="3.5"
ry="2.5"
fill="none"
stroke="#f59e0b"
stroke-width="1.5"
/>
<!-- Goggle bridge -->
<path
d="M29.5 20 Q32 19 34.5 20"
fill="none"
stroke="#f59e0b"
stroke-width="1.2"
/>
<!-- Goggle lens tint -->
<ellipse
cx="26"
cy="20"
rx="2.5"
ry="1.5"
fill="#f59e0b"
opacity="0.15"
/>
<ellipse
cx="38"
cy="20"
rx="2.5"
ry="1.5"
fill="#f59e0b"
opacity="0.15"
/>
<!-- Hard hat -->
<path
d="M18 16 Q18 6 32 5 Q46 6 46 16"
fill="none"
stroke="#f59e0b"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Hat brim -->
<path
d="M16 16 L48 16"
stroke="#f59e0b"
stroke-width="2"
stroke-linecap="round"
/>
<!-- Hat dome highlight -->
<path
d="M26 9 Q32 7 38 9"
fill="none"
stroke="#fbbf24"
stroke-width="1.5"
stroke-linecap="round"
opacity="0.6"
/>
<!-- Feet -->
<ellipse
cx="26"
cy="56"
rx="4"
ry="2"
fill="none"
stroke="currentColor"
stroke-width="1.5"
/>
<ellipse
cx="38"
cy="56"
rx="4"
ry="2"
fill="none"
stroke="currentColor"
stroke-width="1.5"
/>
</svg>
</template>

View File

@ -17,17 +17,32 @@ const steps = [
<template v-for="(step, i) in steps" :key="step.num">
<Badge
:variant="
store.currentStep === step.num ? 'default' : 'outline'
store.currentStep === step.num
? 'default'
: 'outline'
"
class="cursor-default select-none text-xs"
class="select-none font-mono text-xs"
:class="{
'opacity-40': store.currentStep < step.num,
'cursor-pointer hover:bg-accent':
step.num <= store.maxStepReached
&& step.num !== store.currentStep,
'cursor-default':
step.num > store.maxStepReached
|| step.num === store.currentStep,
}"
@click="
step.num <= store.maxStepReached
? store.goToStep(step.num)
: undefined
"
>
{{ step.num }}. {{ step.label }}
{{ step.num }}.{{ step.label }}
</Badge>
<span v-if="i < steps.length - 1" class="text-muted-foreground"
>&middot;</span
<span
v-if="i < steps.length - 1"
class="text-xs text-muted-foreground/40"
>&rsaquo;</span
>
</template>
</nav>