Render webxdc embeds as a tilted, color-tinted cartridge

Replace the plain launch card with a Game Boy-style cartridge (using
public/cartridge.png) whose label region centers the app icon. The whole
cartridge scales as one image and reacts to the pointer with the
existing useCardTilt 3D effect.

The cartridge is tinted by the icon's dominant color: a new
useDominantColor hook samples the icon in an off-screen canvas, picks
the heaviest hue bucket, and exposes it as HSL. A mask-image layer
masked to the cartridge silhouette blends the color over the grayscale
PNG with mix-blend-mode: color, preserving the shading. Grayscale or
CORS-blocked icons fall back to the original gray cartridge.

The app name moves out from under the cartridge and into the description
card in FileMetadataContent — rendered larger and bolder above the
note's content — while WebxdcEmbed still renders its own name card when
a parent isn't providing one (e.g. the kind 1 imeta path).
This commit is contained in:
Alex Gleason
2026-04-27 01:15:54 -05:00
parent e883309791
commit e9def50a85
5 changed files with 308 additions and 59 deletions
+2 -27
View File
@@ -1,12 +1,12 @@
{
"name": "ditto",
"version": "2.10.4",
"version": "2.10.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ditto",
"version": "2.10.4",
"version": "2.10.5",
"dependencies": {
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
@@ -5768,7 +5768,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5782,7 +5781,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5796,7 +5794,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5810,7 +5807,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5824,7 +5820,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5838,7 +5833,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5852,7 +5846,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5866,7 +5859,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5880,7 +5872,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5894,7 +5885,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5908,7 +5898,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5922,7 +5911,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5936,7 +5924,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5950,7 +5937,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5964,7 +5950,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5978,7 +5963,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5992,7 +5976,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6006,7 +5989,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6020,7 +6002,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6034,7 +6015,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6048,7 +6028,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6062,7 +6041,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6076,7 +6054,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6090,7 +6067,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6104,7 +6080,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

+13 -4
View File
@@ -11,6 +11,7 @@ import { getDisplayName } from '@/lib/getDisplayName';
import { genUserName } from '@/lib/genUserName';
import { getAvatarShape } from '@/lib/avatarShape';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
/** Extract the first value of a tag by name. */
function getTag(tags: string[][], name: string): string | undefined {
@@ -25,10 +26,18 @@ function formatBytes(bytes: number): string {
}
/** YouTube-style description card rendered below media. */
function DescriptionCard({ text }: { text: string }) {
function DescriptionCard({ title, text }: { title?: string; text?: string }) {
if (!title && !text) return null;
return (
<div className="mt-2.5 rounded-xl bg-secondary/50 px-3.5 py-2.5">
<p className="text-sm leading-relaxed text-muted-foreground">{text}</p>
{title && (
<p className="text-base font-semibold text-foreground break-words">{title}</p>
)}
{text && (
<p className={cn('text-sm leading-relaxed text-muted-foreground break-words', title && 'mt-1')}>
{text}
</p>
)}
</div>
);
}
@@ -101,10 +110,10 @@ export function FileMetadataContent({ event, compact }: FileMetadataContentProps
<WebxdcEmbed
url={url}
uuid={webxdcId}
name={appName}
icon={thumb}
showNameCard={false}
/>
{description && <DescriptionCard text={description} />}
<DescriptionCard title={appName} text={description} />
</div>
);
}
+128 -28
View File
@@ -1,11 +1,13 @@
import { useState, useRef, useCallback, useEffect, forwardRef } from 'react';
import { createPortal } from 'react-dom';
import { Blocks, Play, X, Gamepad2 } from 'lucide-react';
import { Blocks, X, Gamepad2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Webxdc, type WebxdcHandle } from '@/components/Webxdc';
import { GameControls } from '@/components/GameControls';
import { useCenterColumn } from '@/contexts/LayoutContext';
import { useAppContext } from '@/hooks/useAppContext';
import { useCardTilt } from '@/hooks/useCardTilt';
import { useDominantColor } from '@/hooks/useDominantColor';
import { useWebxdc } from '@/hooks/useWebxdc';
import { deriveIframeSubdomain } from '@/lib/iframeSubdomain';
import { cn } from '@/lib/utils';
@@ -19,6 +21,12 @@ export interface WebxdcEmbedProps {
name?: string;
/** App icon URL. */
icon?: string;
/**
* If true, renders a description-style card below the cartridge with the
* app name. Defaults to true. Set to false when a parent component is
* rendering its own card (e.g. `FileMetadataContent`).
*/
showNameCard?: boolean;
className?: string;
}
@@ -51,7 +59,7 @@ function useElementRect(el: HTMLElement | null): Rect | null {
* then opens a fullscreen panel (covering the center column on desktop, the
* full screen on mobile) when the user clicks Play — matching the nsite UX.
*/
export function WebxdcEmbed({ url, uuid, name, icon, className }: WebxdcEmbedProps) {
export function WebxdcEmbed({ url, uuid, name, icon, showNameCard = true, className }: WebxdcEmbedProps) {
const [launched, setLaunched] = useState(false);
const [showGamepad, setShowGamepad] = useState(false);
const webxdcHandleRef = useRef<WebxdcHandle>(null);
@@ -77,36 +85,22 @@ export function WebxdcEmbed({ url, uuid, name, icon, className }: WebxdcEmbedPro
}, []);
if (!launched) {
const appName = name ?? 'Webxdc App';
return (
<div
className={cn(
'mt-3 rounded-2xl border border-border bg-secondary/30 overflow-hidden',
className,
)}
className={cn('mt-3 flex flex-col items-center', className)}
onClick={(e) => e.stopPropagation()}
>
<div className="flex flex-col items-center justify-center gap-4 py-8 px-6">
{icon ? (
<img
src={icon}
alt={name ?? 'Webxdc App'}
className="size-14 rounded-2xl object-cover"
/>
) : (
<div className="flex items-center justify-center size-14 rounded-2xl bg-primary/10">
<Blocks className="size-7 text-primary" />
</div>
)}
<p className="text-sm font-medium">{name ?? 'Webxdc App'}</p>
<Button
size="sm"
onClick={() => setLaunched(true)}
className="rounded-full gap-2"
>
<Play className="size-4" />
Launch App
</Button>
</div>
<WebxdcCartridgeButton
appName={appName}
icon={icon}
onLaunch={() => setLaunched(true)}
/>
{showNameCard && (
<div className="mt-2.5 w-full max-w-sm rounded-xl bg-secondary/50 px-3.5 py-2.5">
<p className="text-base font-semibold text-foreground break-words">{appName}</p>
</div>
)}
</div>
);
}
@@ -190,6 +184,112 @@ export function WebxdcEmbed({ url, uuid, name, icon, className }: WebxdcEmbedPro
);
}
/**
* Interactive cartridge button with a 3D mouse-tilt effect. The tilt hook
* needs to own the pointer events on a `<div>`, so the button sits inside
* the tilted wrapper rather than the other way around.
*/
function WebxdcCartridgeButton({
appName,
icon,
onLaunch,
}: {
appName: string;
icon?: string;
onLaunch: () => void;
}) {
// Matches the subtle feel used by encrypted letters (18° max, 1.03x scale).
const tilt = useCardTilt(18, 1.03, 800);
const dominant = useDominantColor(icon);
// Boost saturation + keep mid-lightness so the tint reads vividly when
// blended with the cartridge's mid-gray shading via `mix-blend-mode: color`.
const tintColor = dominant
? `hsl(${dominant.h.toFixed(1)}, ${Math.min(100, Math.max(70, dominant.s * 100 + 20)).toFixed(1)}%, 50%)`
: null;
return (
<div
ref={tilt.ref}
style={{
...tilt.style,
// Allow vertical page scrolling to still work on touch — tilt is a
// bonus, not the primary interaction (tap to launch is).
touchAction: 'pan-y',
}}
onPointerDown={tilt.onPointerDown}
onPointerMove={tilt.onPointerMove}
onPointerUp={tilt.onPointerUp}
onPointerLeave={tilt.onPointerLeave}
className="w-full max-w-sm"
>
<button
type="button"
onClick={onLaunch}
aria-label={`Launch ${appName}`}
className={cn(
'relative block w-full bg-transparent p-0 border-0',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded-xl',
// Create an isolated stacking context so the tint's mix-blend-mode
// only blends within this button, not with whatever is behind it.
'isolate',
)}
>
{/* Cartridge background image establishes aspect ratio; icon is absolutely positioned over the label */}
<img
src="/cartridge.png"
alt=""
aria-hidden="true"
className="w-full h-auto block select-none pointer-events-none drop-shadow-md"
draggable={false}
/>
{/* Color tint layer — masked to cartridge silhouette, blended with the
grayscale PNG beneath to colorize it while preserving shading. */}
{tintColor && (
<div
aria-hidden="true"
className="absolute inset-0 pointer-events-none"
style={{
backgroundColor: tintColor,
WebkitMaskImage: 'url(/cartridge.png)',
maskImage: 'url(/cartridge.png)',
WebkitMaskSize: '100% 100%',
maskSize: '100% 100%',
WebkitMaskRepeat: 'no-repeat',
maskRepeat: 'no-repeat',
mixBlendMode: 'color',
}}
/>
)}
{/* Label region — coordinates match the inset rectangle in cartridge.png (1024x1024) */}
<div
className="absolute flex items-center justify-center"
style={{
left: '21.48%',
top: '29.66%',
width: '57.32%',
height: '45.99%',
}}
>
{icon ? (
<img
src={icon}
alt=""
aria-hidden="true"
className="w-[70%] aspect-square rounded-[12%] object-cover drop-shadow-md"
draggable={false}
/>
) : (
<div className="w-[70%] aspect-square rounded-[12%] bg-primary/15 flex items-center justify-center drop-shadow-md">
<Blocks className="w-1/2 h-1/2 text-primary" />
</div>
)}
</div>
</button>
</div>
);
}
/**
* Inner component that renders the actual webxdc iframe.
* Separated so the useWebxdc hook only runs when the app is launched.
+165
View File
@@ -0,0 +1,165 @@
import { useEffect, useState } from 'react';
/** RGB triple returned by the hook, or null if extraction failed. */
export interface DominantColor {
r: number;
g: number;
b: number;
/** HSL representation for easy tinting. */
h: number;
s: number;
l: number;
}
/** In-memory cache so repeat renders of the same icon don't re-sample. */
const cache = new Map<string, DominantColor | null>();
/** Downscaled canvas edge for sampling — keeps cost tiny. */
const SAMPLE_SIZE = 32;
/** Pixels with alpha below this are skipped. */
const ALPHA_THRESHOLD = 128;
/** Pixels with very low saturation (near gray/white/black) are skipped. */
const MIN_SATURATION = 0.15;
/** Pixels this close to pure white or black are skipped. */
const L_EDGE = 0.08;
function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
const rn = r / 255, gn = g / 255, bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const l = (max + min) / 2;
let h = 0;
let s = 0;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case rn: h = (gn - bn) / d + (gn < bn ? 6 : 0); break;
case gn: h = (bn - rn) / d + 2; break;
case bn: h = (rn - gn) / d + 4; break;
}
h /= 6;
}
return { h: h * 360, s, l };
}
function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
const hn = h / 360;
if (s === 0) {
const v = Math.round(l * 255);
return { r: v, g: v, b: v };
}
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
return {
r: Math.round(hue2rgb(p, q, hn + 1 / 3) * 255),
g: Math.round(hue2rgb(p, q, hn) * 255),
b: Math.round(hue2rgb(p, q, hn - 1 / 3) * 255),
};
}
/**
* Extract a dominant hue from an image by sampling pixels in a downscaled
* canvas and averaging the chromatic ones (skipping transparent, near-gray,
* near-white, and near-black pixels).
*
* Returns `null` if the image can't be loaded (CORS failure) or has no
* discernible dominant color (e.g. pure-grayscale icon).
*/
export function useDominantColor(url: string | undefined): DominantColor | null {
const [color, setColor] = useState<DominantColor | null>(() => (url && cache.has(url) ? cache.get(url)! : null));
useEffect(() => {
if (!url) { setColor(null); return; }
if (cache.has(url)) { setColor(cache.get(url)!); return; }
let cancelled = false;
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
if (cancelled) return;
try {
const canvas = document.createElement('canvas');
canvas.width = SAMPLE_SIZE;
canvas.height = SAMPLE_SIZE;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) { cache.set(url, null); setColor(null); return; }
ctx.drawImage(img, 0, 0, SAMPLE_SIZE, SAMPLE_SIZE);
const { data } = ctx.getImageData(0, 0, SAMPLE_SIZE, SAMPLE_SIZE);
// Hue histogram (18 buckets × 20°), weighted by saturation × chroma-ish.
const BUCKETS = 18;
const weights = new Float32Array(BUCKETS);
const sSum = new Float32Array(BUCKETS);
const lSum = new Float32Array(BUCKETS);
const counts = new Uint32Array(BUCKETS);
for (let i = 0; i < data.length; i += 4) {
const a = data[i + 3];
if (a < ALPHA_THRESHOLD) continue;
const r = data[i], g = data[i + 1], b = data[i + 2];
const { h, s, l } = rgbToHsl(r, g, b);
if (s < MIN_SATURATION) continue;
if (l < L_EDGE || l > 1 - L_EDGE) continue;
const bucket = Math.min(BUCKETS - 1, Math.floor((h / 360) * BUCKETS));
// Weight by saturation so vivid pixels dominate over muted ones.
const w = s;
weights[bucket] += w;
sSum[bucket] += s * w;
lSum[bucket] += l * w;
counts[bucket] += 1;
}
// Find the bucket with the highest total weight.
let best = -1, bestWeight = 0;
for (let i = 0; i < BUCKETS; i++) {
if (weights[i] > bestWeight) { bestWeight = weights[i]; best = i; }
}
if (best < 0 || counts[best] < 4) {
cache.set(url, null);
setColor(null);
return;
}
// Bucket midpoint hue, saturation/lightness averaged within the bucket.
const h = (best + 0.5) * (360 / BUCKETS);
const s = sSum[best] / weights[best];
const l = lSum[best] / weights[best];
const rgb = hslToRgb(h, s, l);
const result: DominantColor = { ...rgb, h, s, l };
cache.set(url, result);
setColor(result);
} catch {
// Canvas taint (CORS) or other failure — fall back to null.
cache.set(url, null);
setColor(null);
}
};
img.onerror = () => {
if (cancelled) return;
cache.set(url, null);
setColor(null);
};
img.src = url;
return () => { cancelled = true; };
}, [url]);
return color;
}