feat: massive layout resemblence improvement

This commit is contained in:
2026-05-04 23:22:55 +07:00 Unverified
parent 17875672c5
commit 69a2ac9f90
5 changed files with 267 additions and 37 deletions
+3
View File
@@ -15,6 +15,7 @@
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
}, },
"devDependencies": { "devDependencies": {
"@elysia/cors": "^1.4.2",
"bun-types": "latest", "bun-types": "latest",
"fastest-levenshtein": "^1.0.16", "fastest-levenshtein": "^1.0.16",
"oxfmt": "^0.47.0", "oxfmt": "^0.47.0",
@@ -28,6 +29,8 @@
"packages": { "packages": {
"@borewit/text-codec": ["@borewit/[email protected]", "", {}, "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw=="], "@borewit/text-codec": ["@borewit/[email protected]", "", {}, "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw=="],
"@elysia/cors": ["@elysia/[email protected]", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-dXkfo1IxZWJkJReNYnPJorJqb+XpWno0D/YWlMEJSkiFIFd751zJ4R/YswAYdo88mLWo8cycEj673o7L+gbxsw=="],
"@elysiajs/openapi": ["@elysiajs/[email protected]", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-kWmJWdvP8/LwHwAJXSpz6xFfYUoyUyEPRimEYABuDU1rOnS27Da1u9T2jyU7frOopxKWV/wDfDxMP8z2xdCPJw=="], "@elysiajs/openapi": ["@elysiajs/[email protected]", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-kWmJWdvP8/LwHwAJXSpz6xFfYUoyUyEPRimEYABuDU1rOnS27Da1u9T2jyU7frOopxKWV/wDfDxMP8z2xdCPJw=="],
"@napi-rs/canvas": ["@napi-rs/[email protected]", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.96", "@napi-rs/canvas-darwin-arm64": "0.1.96", "@napi-rs/canvas-darwin-x64": "0.1.96", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.96", "@napi-rs/canvas-linux-arm64-gnu": "0.1.96", "@napi-rs/canvas-linux-arm64-musl": "0.1.96", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.96", "@napi-rs/canvas-linux-x64-gnu": "0.1.96", "@napi-rs/canvas-linux-x64-musl": "0.1.96", "@napi-rs/canvas-win32-arm64-msvc": "0.1.96", "@napi-rs/canvas-win32-x64-msvc": "0.1.96" } }, "sha512-6NNmNxvoJKeucVjxaaRUt3La2i5jShgiAbaY3G/72s1Vp3U06XPrAIxkAjBxpDcamEn/t+WJ4OOlGmvILo4/Ew=="], "@napi-rs/canvas": ["@napi-rs/[email protected]", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.96", "@napi-rs/canvas-darwin-arm64": "0.1.96", "@napi-rs/canvas-darwin-x64": "0.1.96", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.96", "@napi-rs/canvas-linux-arm64-gnu": "0.1.96", "@napi-rs/canvas-linux-arm64-musl": "0.1.96", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.96", "@napi-rs/canvas-linux-x64-gnu": "0.1.96", "@napi-rs/canvas-linux-x64-musl": "0.1.96", "@napi-rs/canvas-win32-arm64-msvc": "0.1.96", "@napi-rs/canvas-win32-x64-msvc": "0.1.96" } }, "sha512-6NNmNxvoJKeucVjxaaRUt3La2i5jShgiAbaY3G/72s1Vp3U06XPrAIxkAjBxpDcamEn/t+WJ4OOlGmvILo4/Ew=="],
+216
View File
@@ -0,0 +1,216 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<style>
body {
margin: 0;
background: #111;
color: white;
font-family: sans-serif;
overflow: auto;
}
.bar {
position: sticky;
top: 0;
z-index: 10;
background: #000c;
padding: 8px;
display: flex;
gap: 12px;
align-items: center;
}
button,
select,
input {
font: inherit;
}
canvas {
display: block;
width: 1870px;
height: 778px;
margin: 12px auto;
background: #000;
}
</style>
</head>
<body>
<div class="bar">
<button id="refresh">refresh</button>
<label>
mode
<select id="mode">
<option value="edge">edge diff</option>
<option value="diff">raw diff</option>
<option value="overlay">red/green overlay</option>
</select>
</label>
<label>
threshold
<input id="threshold" type="range" min="0" max="120" value="85">
<span id="thresholdText">85</span>
</label>
</div>
<canvas id="canvas" width="1870" height="778"></canvas>
<script>
const MINE = "http://nyx:3000/v1/card/genshin/775956514/ororon";
const ORIG = "https://media.discordapp.net/attachments/1234762106558218332/1500814127566684202/81a07207-4f8e-4adb-a3c3-dea113477246.png?ex=69f9cd93&is=69f87c13&hm=7708cde2a1161a7d0b26655902d29f93b297e54293725912e981e3e0701b37eb&=&format=webp&quality=lossless&width=1870&height=778";
const W = 1870, H = 778;
/**
* @type {HTMLCanvasElement}
*/
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d", { willReadFrequently: true });
const mode = document.getElementById("mode");
const threshold = document.getElementById("threshold");
const thresholdText = document.getElementById("thresholdText");
let mineData, origData, refreshing = false;
function loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src + (src.includes("?") ? "&" : "?") + "cacheBust=" + Date.now();
});
}
function drawToData(img) {
const c = document.createElement("canvas");
c.width = W;
c.height = H;
const x = c.getContext("2d", { willReadFrequently: true });
x.drawImage(img, 0, 0, W, H);
return x.getImageData(0, 0, W, H);
}
async function refresh() {
if (refreshing) return;
refreshing = true;
ctx.fillStyle = "#00000088";
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = "white";
ctx.fillText("fetching...", 20, 30);
const [mineImg, origImg] = await Promise.all([
loadImage(MINE),
loadImage(ORIG),
]);
ctx.fillText("loading...", 20, 46);
mineData = drawToData(mineImg);
if (!origData) origData = drawToData(origImg);
ctx.fillText("rendering...", 20, 62);
render();
refreshing = false;
}
function lum(d, i) {
return d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114;
}
function edgeAt(data, x, y) {
const d = data.data;
const i = (y * W + x) * 4;
const l = lum(d, i);
const lx = x > 0 ? lum(d, i - 4) : l;
const rx = x < W - 1 ? lum(d, i + 4) : l;
const uy = y > 0 ? lum(d, i - W * 4) : l;
const dy = y < H - 1 ? lum(d, i + W * 4) : l;
return Math.abs(rx - lx) + Math.abs(dy - uy);
}
function render() {
if (!mineData || !origData) return;
const out = ctx.createImageData(W, H);
const o = out.data;
const m = mineData.data;
const r = origData.data;
const t = Number(threshold.value);
thresholdText.textContent = t;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const i = (y * W + x) * 4;
if (mode.value === "edge") {
const me = edgeAt(mineData, x, y);
const oe = edgeAt(origData, x, y);
const mr = me > t ? 255 : 0;
const og = oe > t ? 255 : 0;
o[i] = mr; // mine = red
o[i + 1] = og; // original = green
o[i + 2] = 0;
o[i + 3] = Math.max(mr, og);
}
else if (mode.value === "diff") {
const dr = Math.abs(m[i] - r[i]);
const dg = Math.abs(m[i + 1] - r[i + 1]);
const db = Math.abs(m[i + 2] - r[i + 2]);
const diff = Math.max(dr, dg, db);
o[i] = diff;
o[i + 1] = diff;
o[i + 2] = diff;
o[i + 3] = diff > t ? 255 : 0;
}
else {
o[i] = m[i]; // mine red-ish
o[i + 1] = r[i + 1]; // original green-ish
o[i + 2] = 0;
o[i + 3] = 220;
}
}
}
ctx.putImageData(out, 0, 0);
}
document.getElementById("refresh").onclick = refresh;
mode.oninput = render;
threshold.oninput = render;
refresh().catch(err => {
console.error(err);
ctx.fillStyle = "red";
ctx.fillText(String(err), 20, 30);
});
// ENTER = refresh (anywhere, unless typing in input)
window.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !["INPUT", "SELECT"].includes(e.target.tagName)) {
e.preventDefault();
refresh();
}
});
// WINDOW FOCUS = refresh
window.addEventListener("focus", () => {
refresh();
});
</script>
</body>
</html>
+1
View File
@@ -18,6 +18,7 @@
"react-dom": "^19.2.4" "react-dom": "^19.2.4"
}, },
"devDependencies": { "devDependencies": {
"@elysia/cors": "^1.4.2",
"bun-types": "latest", "bun-types": "latest",
"fastest-levenshtein": "^1.0.16", "fastest-levenshtein": "^1.0.16",
"oxfmt": "^0.47.0", "oxfmt": "^0.47.0",
+2
View File
@@ -5,10 +5,12 @@ import { version } from "../package.json";
import { pm } from "./lib/auth"; import { pm } from "./lib/auth";
import { createLogger } from "./lib/logger"; import { createLogger } from "./lib/logger";
import { v1routes } from "./v1"; import { v1routes } from "./v1";
import { cors } from '@elysia/cors'
const log = createLogger("APP"); const log = createLogger("APP");
const app = new Elysia() const app = new Elysia()
.use(cors())
.use( .use(
openapi({ openapi({
exclude: { paths: ["/", "/health"] }, exclude: { paths: ["/", "/health"] },
+45 -37
View File
@@ -29,6 +29,7 @@ import { assets, batchStore, getAvatar } from "@/lib/assets";
import { Deduplicator } from "@/lib/dedupe"; import { Deduplicator } from "@/lib/dedupe";
import { cardErrorLocs } from "@/lib/locs"; import { cardErrorLocs } from "@/lib/locs";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
import { resolveShowcaseChar } from "@/lib/matcher";
import { mockData } from "@/lib/mock"; import { mockData } from "@/lib/mock";
import { buildDisplayRows } from "@/lib/rows"; import { buildDisplayRows } from "@/lib/rows";
import type { RollSummary } from "@/lib/substat"; import type { RollSummary } from "@/lib/substat";
@@ -36,9 +37,9 @@ import { buildRollSummary } from "@/lib/substat";
import { Locale, SubstatOrder } from "@/type/enka"; import { Locale, SubstatOrder } from "@/type/enka";
import { FightPropMapEnum, PercentageFightProp } from "@/type/fightPropMap"; import { FightPropMapEnum, PercentageFightProp } from "@/type/fightPropMap";
import DiffPage from "../../hmr.html";
import { ErrorPage } from "./error"; import { ErrorPage } from "./error";
import { ListPage } from "./list"; import { ListPage } from "./list";
import { resolveShowcaseChar } from "@/lib/matcher";
const cache = new TTLCache(); const cache = new TTLCache();
const deduper = new Deduplicator(); const deduper = new Deduplicator();
@@ -51,6 +52,7 @@ queueMicrotask(() => {
if (Date.now() - purging < 60000) return; if (Date.now() - purging < 60000) return;
purging = Date.now(); purging = Date.now();
assets.getMissingAssets(); assets.getMissingAssets();
if (!feature("API")) batchStore("locs", "affixes");
setInterval(async () => { setInterval(async () => {
if (Date.now() - purging < 60000) return; if (Date.now() - purging < 60000) return;
@@ -70,6 +72,7 @@ export function card(app: App) {
.derive(() => ({ .derive(() => ({
log: createLogger(), log: createLogger(),
})) }))
.get("/genshin/diff", DiffPage)
.head( .head(
"/genshin/:uid/preload", "/genshin/:uid/preload",
async ({ params: { uid }, log }) => { async ({ params: { uid }, log }) => {
@@ -464,7 +467,7 @@ export function card(app: App) {
// #region Rendering // #region Rendering
const res = new ImageResponse( const res = new ImageResponse(
<div tw={`text-[${32.25 * (height / 1024)}px]`}> <div tw={`text-[${30.466 * (height / 1024)}px]`}>
<SubstatDisplay <SubstatDisplay
substat={rollSummary} substat={rollSummary}
element={avatar.Element} element={avatar.Element}
@@ -479,34 +482,38 @@ export function card(app: App) {
tw="w-full h-full object-cover" tw="w-full h-full object-cover"
/> />
</div> </div>
{/* avatar */}
<img
src="/avatar"
alt="char"
tw="object-cover h-full aspect-[1024/1124] absolute top-0 -left-[2.0em] scale-x-[1.1] scale-y-[1.15]"
/>
{/* left section */} {/* left section */}
<div <div
tw="absolute top-0 bottom-0 left-0 w-[27.4em] p-[1.4em] flex flex-col" tw="absolute top-0 bottom-0 left-0 w-[30.4em] p-[1.6em] flex flex-col"
style={{ style={{
textShadow: textShadow:
"0 .08em .1em black,0 .1em .3em rgba(0,0,0,.4)", "0 .08em .1em black,0 .1em .3em rgba(0,0,0,.4)",
}} }}
> >
{/* avatar */}
<img
src="/avatar"
alt="char"
tw="absolute h-full aspect-[1024/1124] left-[50%] top-[55%] max-w-none"
style={{
transform: "translate(-50%, -50%) scale(1.3)",
transformOrigin: "50% 50%",
}}
/>
{/* left side vignette */} {/* left side vignette */}
<div tw="absolute top-0 left-0 bottom-0 right-0"> <div tw="absolute top-0 left-0 bottom-0 right-0">
<img src="shade" alt="shade" tw="opacity-40" /> <img src="shade" alt="shade" tw="opacity-40" />
</div> </div>
{/* char and username */} {/* char and username */}
<div tw="text-[130%] pb-[.5%] flex items-center gap-[.5em]"> <div tw="text-[120%] pb-[.5%] flex items-center gap-[.5em]">
{char.avatar.name} {char.avatar.name}
<div tw="text-[0.75em] opacity-80 flex items-center gap-[.4em]"> <div tw="text-[0.75em] opacity-80 flex items-center gap-[.7em] pl-[.4em] pt-[.5em]">
<svg <svg
width="16" width="16"
height="16" height="16"
viewBox="0 0 100 100" viewBox="0 0 100 100"
tw="mt-[.2em] h-[.5em] w-[.5em]" tw="mt-[.2em] h-[.7em] w-[.7em]"
> >
<title>doritos</title> <title>doritos</title>
<polygon <polygon
@@ -518,12 +525,12 @@ export function card(app: App) {
</div> </div>
</div> </div>
{/* level */} {/* level */}
<div> <div tw="text-[105%] pt-[.2em]">
Lv. <span tw="px-1">{charLvl}</span> / Lv. <span tw="px-[.3em]">{charLvl}</span> /
<span tw="opacity-60 pl-1">{charLvlMax}</span> <span tw="opacity-60 pl-[.3em]">{charLvlMax}</span>
</div> </div>
{/* fren */} {/* fren */}
<div tw="flex gap-1 items-end"> <div tw="text-[110%] flex gap-[.3em] items-end pt-[.3em]">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
tw="h-[1.2em] mb-[.2em]" tw="h-[1.2em] mb-[.2em]"
@@ -572,7 +579,7 @@ export function card(app: App) {
style={{ style={{
display: "flex", display: "flex",
position: "absolute", position: "absolute",
right: "5%", right: "4.1%",
bottom: "6%", bottom: "6%",
flexDirection: "column", flexDirection: "column",
alignItems: "flex-end", alignItems: "flex-end",
@@ -595,7 +602,7 @@ export function card(app: App) {
return ( return (
<div tw="mt-[.6em] relative"> <div tw="mt-[.6em] relative">
<TalentSvg <TalentSvg
tw={`Talents ${avatar.Element} w-[3.6em] opacity-60 text-black`} tw={`Talents ${avatar.Element} w-[3.6em] opacity-60 text-black mb-[.5em] -mt-[.25em]`}
/> />
<img <img
key={key} key={key}
@@ -614,12 +621,12 @@ export function card(app: App) {
); );
})} })}
</div> </div>
<div tw="mb-[-1em] ml-[-.3em] mt-[.3em] text-[.9em] opacity-80"> <div tw="mb-[-1em] ml-[-.3em] mt-[.3em] text-[.96em] opacity-80">
{data.uid} {data.uid}
</div> </div>
</div> </div>
{/* middle section */} {/* middle section */}
<div tw="absolute top-0 bottom-0 right-[23.1em] w-[25.5em] p-[1.4em_.7em_.7em] flex flex-col"> <div tw="absolute top-0 bottom-0 right-[24.2em] w-[25.5em] p-[1.5em_.7em_.7em] flex flex-col text-[1.01em]">
{/* weapon */} {/* weapon */}
<div tw="flex flex-start" className="Weapon"> <div tw="flex flex-start" className="Weapon">
{/* display */} {/* display */}
@@ -637,9 +644,9 @@ export function card(app: App) {
}[weapon.store.Rarity], }[weapon.store.Rarity],
}} }}
/> />
<div tw="absolute w-full top-[93%] flex justify-center"> <div tw="absolute w-full top-[91%] flex justify-center">
{Array.from({ length: weapon.store.Rarity }, () => {Array.from({ length: weapon.store.Rarity }, () =>
star(".7em"), star(".8em"),
)} )}
</div> </div>
</div> </div>
@@ -657,7 +664,7 @@ export function card(app: App) {
</span> </span>
</div> </div>
{/* stats */} {/* stats */}
<div tw="pt-[3%] pb-[2%] flex gap-[.5em]"> <div tw="pt-[3%] pb-[2%] flex gap-[.7em]">
{weapon.flat.weaponStats?.map((s) => ( {weapon.flat.weaponStats?.map((s) => (
<div tw="rounded-[.2em] leading-none bg-white/15 px-[.34em] py-[0.25em] flex items-center gap-[.7em] text-[1.03em]"> <div tw="rounded-[.2em] leading-none bg-white/15 px-[.34em] py-[0.25em] flex items-center gap-[.7em] text-[1.03em]">
<EnkaIcon <EnkaIcon
@@ -677,7 +684,7 @@ export function card(app: App) {
</div> </div>
))} ))}
</div> </div>
<div tw="flex gap-[.5em]"> <div tw="flex gap-[.85em]">
<div tw="rounded-[.2em] leading-none bg-black/50 px-[.34em] py-[0.175em] flex items-center gap-[.7em] text-[1.03em] text-[wheat]"> <div tw="rounded-[.2em] leading-none bg-black/50 px-[.34em] py-[0.175em] flex items-center gap-[.7em] text-[1.03em] text-[wheat]">
<div tw="pt-[.1em]"> <div tw="pt-[.1em]">
R R
@@ -688,7 +695,8 @@ export function card(app: App) {
</div> </div>
<div tw="rounded-[.2em] leading-none bg-black/50 px-[.34em] py-[0.175em] flex items-center gap-[.7em] text-[1.03em]"> <div tw="rounded-[.2em] leading-none bg-black/50 px-[.34em] py-[0.175em] flex items-center gap-[.7em] text-[1.03em]">
<div tw="pt-[.1em]"> <div tw="pt-[.1em]">
Lv. {weapon.data.level} / <span tw="pr-[.5em]">Lv.</span>{" "}
{weapon.data.level} /
<span tw="opacity-60 pl-1"> <span tw="opacity-60 pl-1">
{ {
promoteMaxLevelMap[ promoteMaxLevelMap[
@@ -702,7 +710,7 @@ export function card(app: App) {
</div> </div>
</div> </div>
{/* stats table */} {/* stats table */}
<div tw="text-white flex flex-col pt-[2em] pr-[3%] grow text-[.95em] justify-between"> <div tw="text-white flex flex-col pt-[2.2em] pr-[4.7%] -mb-[.8em] grow text-[.96em] justify-between">
{/* <div /> justify spacer */} {/* <div /> justify spacer */}
{statRows.map((r, i) => ( {statRows.map((r, i) => (
<div tw="flex items-center whitespace-nowrap"> <div tw="flex items-center whitespace-nowrap">
@@ -721,7 +729,7 @@ export function card(app: App) {
> >
)[r.key] || (r.key as keyof typeof library) )[r.key] || (r.key as keyof typeof library)
} }
tw="h-[1em] mr-[.8em] -mt-[.05em]" tw="h-[1em] mr-[1em] -mt-[.05em]"
/> />
<div tw="flex grow justify-between"> <div tw="flex grow justify-between">
<div>{r.name}</div> <div>{r.name}</div>
@@ -745,7 +753,7 @@ export function card(app: App) {
))} ))}
{artifactSets.length && ( {artifactSets.length && (
<div <div
tw="flex w-full pt-[.5em] gap-[.5em]" tw="flex w-full gap-[.5em]"
style={{ style={{
textShadow: textShadow:
"0 .08em .1em rgba(0,0,0,.4),0 .1em .3em rgba(0,0,0,.4)", "0 .08em .1em rgba(0,0,0,.4),0 .1em .3em rgba(0,0,0,.4)",
@@ -771,23 +779,23 @@ export function card(app: App) {
</div> </div>
</div> </div>
{/* right section */} {/* right section */}
<div tw="absolute top-0 bottom-0 right-0 w-[23.1em] p-[.7em] flex flex-col justify-between"> <div tw="absolute top-0 bottom-0 right-0 w-[25.7em] p-[.7em] flex flex-col justify-between">
{artifacts.map((a) => {artifacts.map((a) =>
a ? ( a ? (
<div tw="h-[18%] bg-[#0000004f] rounded-[.3em] relative text-white overflow-hidden flex justify-between gap-[.5em]"> <div tw="h-[18%] bg-[#0000004f] rounded-[.3em] relative text-white overflow-hidden flex justify-between gap-[.5em]">
<img <img
src={a.icon} src={a.icon}
alt="artifact" alt="artifact"
tw="absolute top-1/2 -left-[8%] w-[45%] -translate-y-1/2 -translate-x-[10%] object-contain aspect-square scale-90" tw="absolute top-[48%] -left-[8%] w-[45%] -translate-y-1/2 -translate-x-[10%] object-contain aspect-square scale-90"
/> />
{/* main */} {/* main */}
<div tw="h-full w-[30%] text-right shrink-0 flex flex-col justify-center mr-[.25em]"> <div tw="h-full w-[30%] text-[105%] text-right shrink-0 flex flex-col justify-center mr-[.25em]">
<EnkaIcon <EnkaIcon
icon={a.main.mainPropId} icon={a.main.mainPropId}
tw="h-[1em] w-[1em] ml-auto max-h-[22%]" tw="h-[1em] w-[1em] ml-auto max-h-[22%] mt-[.5em]"
/> />
<div <div
tw="text-[1.1em] py-[2%] ml-auto" tw="text-[1.1em] pt-[2%] ml-auto"
style={{ textShadow: "0 .07em .1em black" }} style={{ textShadow: "0 .07em .1em black" }}
> >
{PercentageFightProp.includes( {PercentageFightProp.includes(
@@ -797,7 +805,7 @@ export function card(app: App) {
? `${a.main.statValue.toFixed(1)}%` ? `${a.main.statValue.toFixed(1)}%`
: a.main.statValue.toLocaleString()} : a.main.statValue.toLocaleString()}
</div> </div>
<div tw="flex grow max-h-[20%] items-end justify-end"> <div tw="flex grow max-h-[20%] items-center justify-end">
<div> <div>
{Array.from({ length: a.stars ?? 0 }, () => {Array.from({ length: a.stars ?? 0 }, () =>
star(".6em"), star(".6em"),
@@ -816,7 +824,7 @@ export function card(app: App) {
<div tw="flex flex-col flex-wrap mt-[.35em] mb-[.35em] mr-[.35em] w-full"> <div tw="flex flex-col flex-wrap mt-[.35em] mb-[.35em] mr-[.35em] w-full">
{a.sub.map((s, i) => ( {a.sub.map((s, i) => (
<div <div
tw={`flex items-center pl-[.25em] relative gap-[.25em] w-1/2 h-1/2 ${i === 3 ? "opacity-80" : ""}`} tw={`flex items-center pl-[.7em] relative gap-[.5em] w-1/2 h-1/2 ${i === 3 ? "opacity-80" : ""} ${i > 1 ? "pl-[.3em]" : ""}`}
> >
{enableRollQuality && ( {enableRollQuality && (
<div tw="absolute top-0 m-[.1em] left-[-.25em] bottom-0 right-[.25em] bg-gradient-to-t from-transparent to-black/25 rounded-[.2em] flex gap-0.5"> <div tw="absolute top-0 m-[.1em] left-[-.25em] bottom-0 right-[.25em] bg-gradient-to-t from-transparent to-black/25 rounded-[.2em] flex gap-0.5">
@@ -840,9 +848,9 @@ export function card(app: App) {
)} )}
<EnkaIcon <EnkaIcon
icon={s.appendPropId} icon={s.appendPropId}
tw="h-[1em] w-[1em]" tw="h-[1.1em] w-[1.1em]"
/> />
<div> <div tw="text-[1.05em]">
+ +
{PercentageFightProp.includes( {PercentageFightProp.includes(
s.appendPropId as (typeof PercentageFightProp)[number], s.appendPropId as (typeof PercentageFightProp)[number],