Spaces:
Sleeping
Sleeping
Download templates/app_index.html from prthm11/scratch-vision-game: direct link, hf CLI and curl.
- Browser
- Download file 48.2 kB
-
https://huggingface.co/spaces/prthm11/scratch-vision-game/resolve/main/templates/app_index.html
- Command line
-
hf download hf://spaces/prthm11/scratch-vision-game/templates/app_index.html
-
curl -L -o app_index.html https://huggingface.co/spaces/prthm11/scratch-vision-game/resolve/main/templates/app_index.html
48.2 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Scratch Vision</title> | |
| <meta name="description" content="Turn a PDF of Scratch 3.0 code into a runnable .sb3 project."> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@500&display=swap" rel="stylesheet"> | |
| <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%23FFAB19'/%3E%3Cpath d='M8 11h5l2 2h2l2-2h5v10H19l-2 2h-2l-2-2H8z' fill='%23fff'/%3E%3C/svg%3E"> | |
| <style> | |
| :root { | |
| --bg: #0a0a10; | |
| --surface: #13131c; | |
| --surface-2: #1b1b27; | |
| --line: #2a2a3a; | |
| --text: #f4f4f8; | |
| --muted: #9a9ab0; | |
| --faint: #5d5d73; | |
| --accent: #ffab19; /* Scratch "events" orange */ | |
| --accent-ink: #1a1200; | |
| --blue: #4c97ff; /* motion */ | |
| --purple: #9966ff; /* looks */ | |
| --green: #59c059; /* operators */ | |
| --red: #ff6680; | |
| --grid: rgba(255, 255, 255, 0.035); | |
| --glow: rgba(255, 171, 25, 0.16); | |
| --radius: 18px; | |
| --font: "Space Grotesk", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; | |
| --mono: "JetBrains Mono", ui-monospace, "Cascadia Code", Consolas, monospace; | |
| color-scheme: dark; | |
| } | |
| :root[data-theme="light"] { | |
| --bg: #f6f5f1; | |
| --surface: #ffffff; | |
| --surface-2: #f0efe9; | |
| --line: #e3e1d9; | |
| --text: #15151d; | |
| --muted: #5f5f70; | |
| --faint: #a2a2b0; | |
| --grid: rgba(0, 0, 0, 0.04); | |
| --glow: rgba(255, 171, 25, 0.22); | |
| color-scheme: light; | |
| } | |
| * { box-sizing: border-box; } | |
| html, body { margin: 0; } | |
| body { | |
| min-height: 100vh; | |
| background: var(--bg); | |
| color: var(--text); | |
| font-family: var(--font); | |
| font-size: 16px; | |
| line-height: 1.5; | |
| -webkit-font-smoothing: antialiased; | |
| overflow-x: hidden; | |
| } | |
| /* Faint pixel grid + one warm glow: the only background decoration. */ | |
| body::before { | |
| content: ""; | |
| position: fixed; inset: 0; | |
| background: | |
| radial-gradient(600px 420px at 78% 38%, var(--glow), transparent 70%), | |
| linear-gradient(var(--grid) 1px, transparent 1px) 0 0 / 32px 32px, | |
| linear-gradient(90deg, var(--grid) 1px, transparent 1px) 0 0 / 32px 32px; | |
| pointer-events: none; | |
| z-index: -1; | |
| } | |
| button { font: inherit; color: inherit; } | |
| a { color: inherit; } | |
| .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } | |
| /* ---------- header ---------- */ | |
| .bar { | |
| max-width: 1160px; margin: 0 auto; | |
| padding: 22px 24px; | |
| display: flex; align-items: center; justify-content: space-between; | |
| } | |
| .brand { display: flex; align-items: center; gap: 10px; font-weight: 600; letter-spacing: -0.01em; } | |
| .brand-mark { width: 28px; height: 28px; } | |
| .bar-right { display: flex; align-items: center; gap: 10px; } | |
| .pill { | |
| font-family: var(--mono); font-size: 12px; color: var(--muted); | |
| border: 1px solid var(--line); border-radius: 999px; padding: 4px 10px; | |
| } | |
| .icon-btn { | |
| width: 36px; height: 36px; display: grid; place-items: center; | |
| background: transparent; border: 1px solid var(--line); border-radius: 10px; | |
| cursor: pointer; color: var(--muted); transition: color .2s, border-color .2s; | |
| } | |
| .icon-btn:hover { color: var(--text); border-color: var(--faint); } | |
| .icon-btn svg { width: 18px; height: 18px; } | |
| :root:not([data-theme="light"]) .sun, :root[data-theme="light"] .moon { display: none; } | |
| /* ---------- layout ---------- */ | |
| main { | |
| max-width: 1160px; margin: 0 auto; | |
| padding: 40px 24px 64px; | |
| display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); | |
| gap: 56px; align-items: center; | |
| } | |
| @media (max-width: 900px) { | |
| main { grid-template-columns: 1fr; gap: 36px; padding-top: 16px; } | |
| } | |
| /* ---------- copy ---------- */ | |
| .eyebrow { | |
| font-family: var(--mono); font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase; | |
| color: var(--accent); margin: 0 0 18px; | |
| } | |
| h1 { | |
| font-size: clamp(40px, 6vw, 68px); line-height: 0.98; letter-spacing: -0.04em; | |
| font-weight: 700; margin: 0 0 22px; | |
| } | |
| h1 .hl { color: var(--accent); } | |
| .lede { color: var(--muted); font-size: 18px; max-width: 30em; margin: 0 0 36px; } | |
| /* Scratch-style block stack: the pipeline, drawn as code. */ | |
| .stack { display: flex; flex-direction: column; gap: 0; width: max-content; max-width: 100%; } | |
| .block { | |
| --c: var(--accent); | |
| position: relative; height: 44px; margin-top: -4px; | |
| display: flex; align-items: center; padding: 0 18px 0 16px; | |
| color: #fff; font-weight: 500; font-size: 15px; white-space: nowrap; | |
| opacity: 0; transform: translateX(-12px); | |
| animation: snap .5s cubic-bezier(.2, .9, .3, 1.3) forwards; | |
| } | |
| .block svg { position: absolute; inset: 0; width: 100%; height: calc(100% + 6px); z-index: -1; overflow: visible; } | |
| .block svg path { fill: var(--c); } | |
| .block span { position: relative; } | |
| .block .chip { | |
| margin-left: 8px; padding: 1px 10px; border-radius: 999px; | |
| background: rgba(255, 255, 255, .92); color: #333; font-size: 13px; | |
| } | |
| .block.hat { height: 58px; padding-top: 12px; } | |
| .block:nth-child(1) { animation-delay: .05s; } | |
| .block:nth-child(2) { animation-delay: .17s; --c: var(--blue); } | |
| .block:nth-child(3) { animation-delay: .29s; --c: var(--purple); } | |
| .block:nth-child(4) { animation-delay: .41s; --c: var(--green); } | |
| @keyframes snap { to { opacity: 1; transform: none; } } | |
| /* ---------- the stage ---------- */ | |
| .stage-wrap { position: relative; } | |
| .stage-head { | |
| display: flex; align-items: center; justify-content: space-between; | |
| font-family: var(--mono); font-size: 12px; color: var(--faint); margin-bottom: 10px; padding: 0 4px; | |
| } | |
| .flag-row { display: flex; gap: 8px; align-items: center; } | |
| .flag-row svg { width: 18px; height: 18px; } | |
| .stage { | |
| position: relative; aspect-ratio: 4 / 3; width: 100%; | |
| background: var(--surface); | |
| border: 1px solid var(--line); border-radius: var(--radius); | |
| overflow: hidden; | |
| box-shadow: 0 30px 80px -40px rgba(0, 0, 0, .6); | |
| } | |
| .scene { | |
| position: absolute; inset: 0; | |
| display: flex; flex-direction: column; align-items: center; justify-content: center; | |
| padding: 32px; text-align: center; | |
| opacity: 0; visibility: hidden; transform: scale(.98); | |
| transition: opacity .35s ease, transform .35s ease, visibility 0s .35s; | |
| } | |
| .scene.on { opacity: 1; visibility: visible; transform: none; transition-delay: 0s; } | |
| /* idle */ | |
| .drop { | |
| position: absolute; inset: 14px; border: 2px dashed var(--line); border-radius: 12px; | |
| display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; | |
| cursor: pointer; transition: border-color .2s, background .2s; | |
| } | |
| .drop:hover, .drop:focus-visible, .stage.dragging .drop { | |
| border-color: var(--accent); background: rgba(255, 171, 25, .05); outline: none; | |
| } | |
| .stage.dragging .drop-icon { transform: translateY(-6px) rotate(-4deg); } | |
| .drop-icon { | |
| width: 72px; height: 72px; border-radius: 20px; display: grid; place-items: center; | |
| background: var(--surface-2); border: 1px solid var(--line); | |
| transition: transform .3s cubic-bezier(.2, .9, .3, 1.4); | |
| } | |
| .drop-icon svg { width: 32px; height: 32px; color: var(--accent); } | |
| .drop-title { font-size: 22px; font-weight: 600; letter-spacing: -0.02em; } | |
| .drop-sub { color: var(--muted); font-size: 14px; } | |
| .drop-sub u { text-decoration-color: var(--accent); text-underline-offset: 3px; } | |
| .drop-hint { position: absolute; bottom: 16px; font-family: var(--mono); font-size: 11px; color: var(--faint); } | |
| /* ready */ | |
| .file-card { | |
| display: flex; align-items: center; gap: 14px; | |
| background: var(--surface-2); border: 1px solid var(--line); border-radius: 14px; | |
| padding: 14px 16px; max-width: 100%; margin-bottom: 26px; text-align: left; | |
| } | |
| .file-ico { | |
| flex: none; width: 40px; height: 48px; border-radius: 6px; background: var(--red); | |
| display: grid; place-items: end center; padding-bottom: 6px; | |
| font-family: var(--mono); font-size: 10px; font-weight: 500; color: #fff; | |
| } | |
| .file-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 280px; } | |
| .file-meta { color: var(--muted); font-size: 13px; font-family: var(--mono); } | |
| .actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; } | |
| .btn { | |
| display: inline-flex; align-items: center; gap: 10px; | |
| height: 48px; padding: 0 22px; border-radius: 12px; border: 1px solid transparent; | |
| font-weight: 600; font-size: 15px; cursor: pointer; text-decoration: none; | |
| transition: transform .15s, box-shadow .2s, background .2s, border-color .2s; | |
| } | |
| .btn svg { width: 18px; height: 18px; } | |
| .btn-primary { background: var(--accent); color: var(--accent-ink); box-shadow: 0 8px 30px -10px var(--accent); } | |
| .btn-primary:hover { transform: translateY(-1px); box-shadow: 0 12px 36px -10px var(--accent); } | |
| .btn-primary:active { transform: translateY(1px); } | |
| .btn-ghost { background: transparent; border-color: var(--line); color: var(--muted); } | |
| .btn-ghost:hover { color: var(--text); border-color: var(--faint); } | |
| .btn:focus-visible, .icon-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } | |
| /* working */ | |
| .boot-title { font-size: 13px; font-family: var(--mono); color: var(--muted); letter-spacing: .1em; text-transform: uppercase; margin-bottom: 18px; } | |
| .sprite-run { height: 56px; margin-bottom: 20px; } | |
| .sprite-run svg { width: 56px; height: 56px; animation: hop .6s ease-in-out infinite alternate; } | |
| @keyframes hop { from { transform: translateY(0) } to { transform: translateY(-10px) } } | |
| .loadbar { | |
| width: min(360px, 100%); height: 14px; padding: 3px; border-radius: 8px; | |
| background: var(--surface-2); border: 1px solid var(--line); margin-bottom: 26px; | |
| } | |
| .loadbar i { | |
| display: block; height: 100%; width: 0; border-radius: 4px; | |
| background: repeating-linear-gradient(90deg, var(--accent) 0 10px, #ffc35c 10px 14px); | |
| transition: width .8s ease; | |
| } | |
| .phases { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; text-align: left; } | |
| .phases li { display: flex; align-items: center; gap: 12px; color: var(--faint); font-size: 15px; transition: color .3s; } | |
| .phases .dot { | |
| width: 18px; height: 18px; border-radius: 50%; border: 2px solid var(--line); flex: none; | |
| display: grid; place-items: center; transition: all .3s; | |
| } | |
| .phases li.active { color: var(--text); } | |
| .phases li.active .dot { border-color: var(--accent); border-top-color: transparent; animation: spin .8s linear infinite; } | |
| .phases li.done { color: var(--muted); } | |
| .phases li.done .dot { border-color: var(--green); background: var(--green); } | |
| .phases li.done .dot::after { content: ""; width: 5px; height: 9px; border: solid #0a0a10; border-width: 0 2px 2px 0; transform: translateY(-1px) rotate(45deg); } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| .elapsed { position: absolute; bottom: 16px; font-family: var(--mono); font-size: 12px; color: var(--faint); } | |
| /* done */ | |
| .win-badge { | |
| font-family: var(--mono); font-size: 12px; letter-spacing: .14em; text-transform: uppercase; | |
| color: var(--green); margin-bottom: 8px; | |
| } | |
| :root[data-theme="light"] .win-badge { color: #2f8a2f; } | |
| :root[data-theme="light"] .eyebrow { color: #b86e00; } | |
| .win-title { font-size: clamp(28px, 4vw, 40px); font-weight: 700; letter-spacing: -0.03em; margin: 0 0 6px; line-height: 1.05; } | |
| .win-sub { color: var(--muted); margin: 0 0 24px; font-size: 15px; } | |
| .stars { display: flex; gap: 6px; justify-content: center; margin-bottom: 14px; } | |
| .stars svg { width: 30px; height: 30px; color: var(--accent); opacity: 0; transform: scale(.3) rotate(-30deg); } | |
| .scene.on .stars svg { animation: pop .5s cubic-bezier(.2, .9, .3, 1.6) forwards; } | |
| .scene.on .stars svg:nth-child(2) { animation-delay: .12s; } | |
| .scene.on .stars svg:nth-child(3) { animation-delay: .24s; } | |
| @keyframes pop { to { opacity: 1; transform: none; } } | |
| /* error */ | |
| .err-ico { width: 56px; height: 56px; border-radius: 16px; display: grid; place-items: center; background: rgba(255, 102, 128, .12); color: var(--red); margin-bottom: 18px; } | |
| .err-ico svg { width: 28px; height: 28px; } | |
| .err-msg { color: var(--muted); max-width: 34em; margin: 0 0 6px; font-size: 15px; overflow-wrap: anywhere; } | |
| .err-id { font-family: var(--mono); font-size: 12px; color: var(--faint); margin: 0 0 22px; overflow-wrap: anywhere; } | |
| .btn[hidden], .fallback-note[hidden] { display: none; } | |
| .fallback-note { color: var(--muted); font-size: 13px; max-width: 34em; margin: 16px 0 0; } | |
| .fallback-note a { color: var(--accent); text-decoration: none; } | |
| .fallback-note a:hover { text-decoration: underline; } | |
| /* ---------- results panel (below the stage) ---------- */ | |
| .panel { | |
| margin-top: 16px; border: 1px solid var(--line); border-radius: var(--radius); | |
| background: var(--surface); padding: 18px 20px; display: none; | |
| } | |
| .panel.on { display: block; animation: rise .4s ease both; } | |
| @keyframes rise { from { opacity: 0; transform: translateY(8px) } } | |
| .panel-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px; } | |
| .panel-head h2 { font-size: 15px; margin: 0; font-weight: 600; } | |
| .panel-head span { font-family: var(--mono); font-size: 12px; color: var(--faint); } | |
| .matches { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; } | |
| /* One card per sprite: the match (figure -> asset, score) on the left, its code on the right. */ | |
| .match { | |
| background: var(--surface-2); border: 1px solid var(--line); border-radius: 14px; | |
| padding: 12px; display: grid; grid-template-columns: minmax(0, 220px) minmax(0, 1fr); gap: 14px; | |
| font-size: 14px; min-width: 0; align-items: start; | |
| } | |
| .match.other { grid-template-columns: minmax(0, 1fr); } | |
| .match .side { display: grid; gap: 10px; min-width: 0; } | |
| .code { min-width: 0; display: grid; gap: 8px; } | |
| .code-head { display: flex; justify-content: space-between; align-items: baseline; font-family: var(--mono); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; color: var(--faint); } | |
| /* Scratch block colours are made for a light canvas, so the board stays light in both themes. */ | |
| .board { background: #f9f9f9; border: 1px solid var(--line); border-radius: 10px; padding: 10px; overflow-x: auto; display: grid; gap: 10px; justify-items: start; } | |
| .board pre.blocks { margin: 0; padding: 0; background: none; color: #575e75; max-height: none; white-space: pre; } | |
| .board svg { display: block; max-width: none; } | |
| .code .empty { margin: 0; font-size: 13px; color: var(--faint); font-style: italic; } | |
| .pair { display: grid; grid-template-columns: minmax(0, 1fr) 18px minmax(0, 1fr); gap: 6px; align-items: center; } | |
| .pair > svg { width: 18px; height: 18px; color: var(--accent); } | |
| .thumb { margin: 0; min-width: 0; } | |
| .thumb .img { | |
| aspect-ratio: 1; border-radius: 10px; overflow: hidden; display: grid; place-items: center; | |
| border: 1px solid var(--line); | |
| /* checkerboard so transparent sprites read as sprites, not as missing images */ | |
| background: conic-gradient(rgba(128, 128, 128, .16) 25%, transparent 0 50%, rgba(128, 128, 128, .16) 0 75%, transparent 0) 0 0 / 14px 14px; | |
| } | |
| .thumb img { width: 100%; height: 100%; object-fit: contain; display: block; } | |
| .thumb figcaption { margin-top: 5px; font-family: var(--mono); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; color: var(--faint); } | |
| .thumb .empty { font-size: 12px; color: var(--faint); font-style: italic; padding: 8px; text-align: center; } | |
| .match .asset { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| .match .asset.none { color: var(--faint); font-weight: 400; font-style: italic; } | |
| .meter { display: flex; align-items: center; gap: 8px; } | |
| .meter b { flex: 1; height: 6px; border-radius: 3px; background: var(--surface-2); overflow: hidden; } | |
| .meter b i { display: block; height: 100%; background: var(--green); border-radius: 3px; } | |
| .meter em { font-style: normal; font-family: var(--mono); font-size: 12px; color: var(--muted); width: 34px; text-align: right; } | |
| .howto { margin: 16px 0 0; padding-top: 14px; border-top: 1px solid var(--line); color: var(--muted); font-size: 13px; } | |
| .howto a { color: var(--accent); text-decoration: none; } | |
| .howto a:hover { text-decoration: underline; } | |
| details { margin-top: 10px; } | |
| summary { cursor: pointer; color: var(--faint); font-size: 12px; font-family: var(--mono); } | |
| pre { font-family: var(--mono); font-size: 12px; color: var(--muted); background: var(--surface-2); padding: 12px; border-radius: 10px; overflow: auto; max-height: 240px; } | |
| /* ---------- sample PDFs (below the stage) ---------- */ | |
| .samples { margin-top: 16px; } | |
| .samples[hidden] { display: none; } | |
| .samples-head { font-family: var(--mono); font-size: 12px; color: var(--faint); margin: 0 0 8px; padding: 0 4px; } | |
| .sample { | |
| display: flex; align-items: center; gap: 14px; flex-wrap: wrap; | |
| background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: 12px 14px; | |
| } | |
| .sample + .sample { margin-top: 8px; } | |
| .sample .file-ico { width: 32px; height: 40px; font-size: 9px; padding-bottom: 4px; } | |
| .sample-info { flex: 1; min-width: 0; } | |
| .sample .file-name { max-width: 100%; } | |
| .sample-actions { display: flex; gap: 8px; } | |
| .btn-sm { height: 36px; padding: 0 14px; border-radius: 10px; font-size: 14px; gap: 8px; box-shadow: none; } | |
| .btn-sm svg { width: 16px; height: 16px; } | |
| .btn[disabled] { opacity: .6; cursor: progress; } | |
| /* PDF viewer */ | |
| .viewer { | |
| width: min(920px, calc(100vw - 32px)); height: min(88vh, 1100px); max-width: none; max-height: none; | |
| padding: 0; border: 1px solid var(--line); border-radius: var(--radius); | |
| background: var(--surface); color: var(--text); overflow: hidden; | |
| } | |
| .viewer[open] { display: flex; flex-direction: column; } | |
| .viewer::backdrop { background: rgba(0, 0, 0, .6); } | |
| .viewer-head { display: flex; align-items: center; gap: 10px; padding: 10px 12px 10px 18px; border-bottom: 1px solid var(--line); flex-wrap: wrap; } | |
| .viewer-head h2 { flex: 1; min-width: 0; margin: 0; font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| .viewer iframe { flex: 1; width: 100%; border: 0; background: #525659; } | |
| footer { max-width: 1160px; margin: 0 auto; padding: 0 24px 32px; color: var(--faint); font-size: 13px; } | |
| @media (max-width: 560px) { | |
| .bar { padding: 16px; } | |
| main { padding: 8px 16px 48px; } | |
| footer { padding: 0 16px 24px; } | |
| .lede { font-size: 16px; margin-bottom: 28px; } | |
| .stack { display: none; } | |
| .stage { aspect-ratio: auto; min-height: 420px; } | |
| .scene { padding: 24px 18px; } | |
| .file-name { max-width: 180px; } | |
| .match { grid-template-columns: minmax(0, 1fr); } | |
| } | |
| @media (prefers-reduced-motion: reduce) { | |
| *, *::before, *::after { animation-duration: .01ms ; animation-iteration-count: 1 ; transition-duration: .01ms ; } | |
| .block, .stars svg { opacity: 1; transform: none; } | |
| } | |
| </style> | |
| <script> | |
| // Apply the saved theme before first paint to avoid a flash. | |
| try { const t = localStorage.getItem("sv-theme"); if (t) document.documentElement.dataset.theme = t; } catch (e) {} | |
| </script> | |
| <script defer id="sbLib" src="https://cdn.jsdelivr.net/npm/scratchblocks@3.6.4/build/scratchblocks.min.js"></script> | |
| </head> | |
| <body> | |
| <header class="bar"> | |
| <div class="brand"> | |
| <svg class="brand-mark" viewBox="0 0 32 32" aria-hidden="true"> | |
| <rect width="32" height="32" rx="8" fill="#FFAB19"/> | |
| <path d="M8 11h5l2 2h2l2-2h5v10H19l-2 2h-2l-2-2H8z" fill="#fff"/> | |
| </svg> | |
| Scratch Vision | |
| </div> | |
| <div class="bar-right"> | |
| <span class="pill">Scratch 3.0</span> | |
| <button class="icon-btn" id="themeBtn" type="button" aria-label="Toggle light and dark theme"> | |
| <svg class="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg> | |
| <svg class="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg> | |
| </button> | |
| </div> | |
| </header> | |
| <main> | |
| <section> | |
| <p class="eyebrow">PDF → .sb3</p> | |
| <h1>Paper code,<br><span class="hl">playable</span> game.</h1> | |
| <p class="lede">Drop a PDF of Scratch blocks. We find the sprites and backdrops, rebuild every script, and hand you a project that runs.</p> | |
| <div class="stack" aria-hidden="true"> | |
| <div class="block hat"> | |
| <svg viewBox="0 0 300 58" preserveAspectRatio="none"><path d="M0 18 C30 -4 80 -4 110 14 H296 a4 4 0 0 1 4 4 V54 a4 4 0 0 1 -4 4 H48 l-4 4 h-12 l-4 -4 H4 a4 4 0 0 1 -4 -4 Z"/></svg> | |
| <span>when</span><span class="chip">PDF</span><span> dropped</span> | |
| </div> | |
| <div class="block"> | |
| <svg viewBox="0 0 300 44" preserveAspectRatio="none"><path d="M0 4 a4 4 0 0 1 4 -4 H28 l4 4 h12 l4 -4 H296 a4 4 0 0 1 4 4 V40 a4 4 0 0 1 -4 4 H48 l-4 4 h-12 l-4 -4 H4 a4 4 0 0 1 -4 -4 Z"/></svg> | |
| <span>match sprites & backdrops</span> | |
| </div> | |
| <div class="block"> | |
| <svg viewBox="0 0 300 44" preserveAspectRatio="none"><path d="M0 4 a4 4 0 0 1 4 -4 H28 l4 4 h12 l4 -4 H296 a4 4 0 0 1 4 4 V40 a4 4 0 0 1 -4 4 H48 l-4 4 h-12 l-4 -4 H4 a4 4 0 0 1 -4 -4 Z"/></svg> | |
| <span>read every script</span> | |
| </div> | |
| <div class="block"> | |
| <svg viewBox="0 0 300 44" preserveAspectRatio="none"><path d="M0 4 a4 4 0 0 1 4 -4 H28 l4 4 h12 l4 -4 H296 a4 4 0 0 1 4 4 V40 a4 4 0 0 1 -4 4 H48 l-4 4 h-12 l-4 -4 H4 a4 4 0 0 1 -4 -4 Z"/></svg> | |
| <span>build</span><span class="chip">.sb3</span> | |
| </div> | |
| </div> | |
| </section> | |
| <section class="stage-wrap" aria-label="Converter"> | |
| <div class="stage-head"> | |
| <div class="flag-row" aria-hidden="true"> | |
| <svg viewBox="0 0 24 24"><path d="M5 3v18" stroke="#59c059" stroke-width="2" stroke-linecap="round"/><path d="M5 4c4-2 7 2 13 0v9c-6 2-9-2-13 0z" fill="#59c059"/></svg> | |
| <svg viewBox="0 0 24 24"><polygon points="8,3 16,3 21,8 21,16 16,21 8,21 3,16 3,8" fill="#ff6680"/></svg> | |
| </div> | |
| <span id="stageLabel">stage · 480×360</span> | |
| </div> | |
| <div class="stage" id="stage"> | |
| <!-- 1. idle --> | |
| <div class="scene on" id="sceneIdle"> | |
| <label class="drop" for="pdfFile" tabindex="0" id="dropZone"> | |
| <div class="drop-icon"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16V4M7 9l5-5 5 5"/><path d="M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3"/></svg> | |
| </div> | |
| <div class="drop-title">Drop your PDF here</div> | |
| <div class="drop-sub">or <u>browse your files</u></div> | |
| <div class="drop-hint">PDF · up to {{ max_upload_mb }} MB</div> | |
| </label> | |
| <input class="sr-only" type="file" id="pdfFile" accept="application/pdf,.pdf"> | |
| </div> | |
| <!-- 2. file chosen --> | |
| <div class="scene" id="sceneReady"> | |
| <div class="file-card"> | |
| <div class="file-ico">PDF</div> | |
| <div> | |
| <div class="file-name" id="fileName"></div> | |
| <div class="file-meta" id="fileMeta"></div> | |
| </div> | |
| </div> | |
| <div class="actions"> | |
| <button class="btn btn-primary" id="buildBtn" type="button"> | |
| <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M5 4c4-2 7 2 13 0v9c-6 2-9-2-13 0z"/><path d="M5 3v18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg> | |
| Build game | |
| </button> | |
| <button class="btn btn-ghost" id="changeBtn" type="button">Change file</button> | |
| </div> | |
| </div> | |
| <!-- 3. working --> | |
| <div class="scene" id="sceneWork" aria-busy="true"> | |
| <div class="boot-title">Building your project</div> | |
| <div class="sprite-run" aria-hidden="true"> | |
| <svg viewBox="0 0 56 56"> | |
| <rect x="8" y="12" width="40" height="34" rx="10" fill="#ffab19"/> | |
| <path d="M12 14 l4 -10 l8 9z M44 14 l-4 -10 l-8 9z" fill="#ffab19"/> | |
| <circle cx="21" cy="27" r="4" fill="#fff"/><circle cx="35" cy="27" r="4" fill="#fff"/> | |
| <circle cx="22" cy="28" r="2" fill="#1a1200"/><circle cx="36" cy="28" r="2" fill="#1a1200"/> | |
| <path d="M24 36 q4 4 8 0" stroke="#1a1200" stroke-width="2" fill="none" stroke-linecap="round"/> | |
| </svg> | |
| </div> | |
| <div class="loadbar" role="progressbar" aria-label="Progress" aria-valuemin="0" aria-valuemax="100" id="loadbar"><i id="loadfill"></i></div> | |
| <ol class="phases" id="phases"> | |
| <li><span class="dot"></span>Reading the PDF</li> | |
| <li><span class="dot"></span>Matching sprites & backdrops</li> | |
| <li><span class="dot"></span>Rebuilding the scripts</li> | |
| <li><span class="dot"></span>Packing your .sb3</li> | |
| </ol> | |
| <div class="elapsed" id="elapsed">0:00 · usually 1–3 min</div> | |
| </div> | |
| <!-- 4. done --> | |
| <div class="scene" id="sceneDone"> | |
| <div class="stars" aria-hidden="true"> | |
| <svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l3 6.6 7.2.7-5.4 4.8 1.6 7.1L12 17.5 5.6 21.2l1.6-7.1L1.8 9.3 9 8.6z"/></svg> | |
| <svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l3 6.6 7.2.7-5.4 4.8 1.6 7.1L12 17.5 5.6 21.2l1.6-7.1L1.8 9.3 9 8.6z"/></svg> | |
| <svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l3 6.6 7.2.7-5.4 4.8 1.6 7.1L12 17.5 5.6 21.2l1.6-7.1L1.8 9.3 9 8.6z"/></svg> | |
| </div> | |
| <div class="win-badge">Level complete</div> | |
| <h2 class="win-title">Your game is ready</h2> | |
| <p class="win-sub" id="winSub"></p> | |
| <div class="actions"> | |
| <a class="btn btn-primary" id="downloadBtn" href="#" download> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v12M7 11l5 5 5-5M5 20h14"/></svg> | |
| Download .sb3 | |
| </a> | |
| <button class="btn btn-ghost" type="button" data-restart>New PDF</button> | |
| </div> | |
| </div> | |
| <!-- 5. error --> | |
| <div class="scene" id="sceneError"> | |
| <div class="err-ico"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M12 7v6M12 17h.01"/><circle cx="12" cy="12" r="10"/></svg> | |
| </div> | |
| <h2 class="win-title">Game over</h2> | |
| <p class="err-msg" id="errMsg"></p> | |
| <p class="err-id" id="errId"></p> | |
| <div class="actions"> | |
| <a class="btn btn-primary" id="fallbackBtn" href="#" download hidden> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v12M7 11l5 5 5-5M5 20h14"/></svg> | |
| Download ready-made .sb3 | |
| </a> | |
| <button class="btn btn-primary" type="button" id="retryBtn">Try again</button> | |
| <button class="btn btn-ghost" type="button" data-restart>Choose another PDF</button> | |
| </div> | |
| <p class="fallback-note" id="fallbackNote" hidden></p> | |
| </div> | |
| </div> | |
| {% if samples %} | |
| <div class="samples" id="samples"> | |
| <p class="samples-head">No PDF handy? Try a sample</p> | |
| {% for s in samples %} | |
| <div class="sample"> | |
| <div class="file-ico">PDF</div> | |
| <div class="sample-info"> | |
| <div class="file-name" title="{{ s.file }}">{{ s.title }}</div> | |
| <div class="file-meta" data-size="{{ s.size }}">{{ s.file }}</div> | |
| </div> | |
| <div class="sample-actions"> | |
| <button class="btn btn-ghost btn-sm" type="button" data-sample-view data-url="{{ s.url }}" data-file="{{ s.file }}" data-title="{{ s.title }}"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg> | |
| View | |
| </button> | |
| <button class="btn btn-primary btn-sm" type="button" data-sample-use data-url="{{ s.url }}" data-file="{{ s.file }}" data-title="{{ s.title }}" data-sb3="{{ s.sb3_url }}"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16V4M7 9l5-5 5 5"/><path d="M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3"/></svg> | |
| Use this PDF | |
| </button> | |
| </div> | |
| </div> | |
| {% endfor %} | |
| </div> | |
| <dialog class="viewer" id="viewer" aria-labelledby="viewerTitle"> | |
| <div class="viewer-head"> | |
| <h2 id="viewerTitle"></h2> | |
| <a class="btn btn-ghost btn-sm" id="viewerOpen" href="#" target="_blank" rel="noopener">Open in new tab</a> | |
| <button class="btn btn-primary btn-sm" type="button" id="viewerUse">Use this PDF</button> | |
| <button class="icon-btn" type="button" id="viewerClose" aria-label="Close preview"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg> | |
| </button> | |
| </div> | |
| <iframe id="viewerFrame" title="Sample PDF preview"></iframe> | |
| </dialog> | |
| {% endif %} | |
| <div class="panel" id="panel"> | |
| <div class="panel-head"> | |
| <h2>Sprites & code</h2> | |
| <span id="matchCount"></span> | |
| </div> | |
| <ul class="matches" id="matches"></ul> | |
| <p class="howto">Open it in <a href="https://scratch.mit.edu/projects/editor/" target="_blank" rel="noopener">Scratch</a>: File → Load from your computer.</p> | |
| <details> | |
| <summary>raw response</summary> | |
| <pre id="raw"></pre> | |
| </details> | |
| </div> | |
| <p class="sr-only" aria-live="polite" id="live"></p> | |
| </section> | |
| </main> | |
| <footer>Upload a PDF of Scratch 3.0 code. Figures are matched against the Kodomo asset library.</footer> | |
| <script> | |
| (() => { | |
| const MAX_MB = Number("{{ max_upload_mb }}") || 50; | |
| const $ = (id) => document.getElementById(id); | |
| const stage = $("stage"), input = $("pdfFile"), live = $("live"); | |
| const scenes = ["sceneIdle", "sceneReady", "sceneWork", "sceneDone", "sceneError"]; | |
| let file = null, timer = null; | |
| function show(id) { | |
| scenes.forEach((s) => $(s).classList.toggle("on", s === id)); | |
| // Samples are offered only while the user is still choosing a file. | |
| if ($("samples")) $("samples").hidden = !["sceneIdle", "sceneReady", "sceneError"].includes(id); | |
| // Move keyboard focus to the new scene's first control. | |
| const target = $(id).querySelector("button:not([hidden]), a:not([hidden]), label[tabindex]"); | |
| if (target && id !== "sceneWork") setTimeout(() => target.focus({ preventScroll: true }), 50); | |
| } | |
| const say = (msg) => { live.textContent = msg; }; | |
| const fmtSize = (b) => b > 1048576 ? (b / 1048576).toFixed(1) + " MB" : Math.max(1, Math.round(b / 1024)) + " KB"; | |
| // ---- theme ---- | |
| $("themeBtn").addEventListener("click", () => { | |
| const root = document.documentElement; | |
| const next = root.dataset.theme === "light" ? "dark" : "light"; | |
| root.dataset.theme = next; | |
| try { localStorage.setItem("sv-theme", next); } catch (e) {} | |
| }); | |
| // ---- choosing a file ---- | |
| function pick(f) { | |
| if (!f) return; | |
| const isPdf = f.type === "application/pdf" || /\.pdf$/i.test(f.name); | |
| if (!isPdf) return fail("That file isn't a PDF. Choose a .pdf file."); | |
| if (f.size > MAX_MB * 1048576) return fail(`That PDF is ${fmtSize(f.size)}. The limit is ${MAX_MB} MB.`); | |
| file = f; | |
| $("fileName").textContent = f.name; | |
| $("fileName").title = f.name; | |
| $("fileMeta").textContent = fmtSize(f.size); | |
| $("panel").classList.remove("on"); | |
| show("sceneReady"); | |
| say(`${f.name} selected. Press Build game.`); | |
| } | |
| input.addEventListener("change", () => pick(input.files[0])); | |
| $("dropZone").addEventListener("keydown", (e) => { | |
| if (e.key === "Enter" || e.key === " ") { e.preventDefault(); input.click(); } | |
| }); | |
| $("changeBtn").addEventListener("click", () => { input.value = ""; input.click(); }); | |
| ["dragenter", "dragover"].forEach((ev) => stage.addEventListener(ev, (e) => { | |
| e.preventDefault(); | |
| if (!$("sceneWork").classList.contains("on")) stage.classList.add("dragging"); | |
| })); | |
| ["dragleave", "drop"].forEach((ev) => stage.addEventListener(ev, (e) => { | |
| e.preventDefault(); | |
| if (ev === "dragleave" && stage.contains(e.relatedTarget)) return; | |
| stage.classList.remove("dragging"); | |
| })); | |
| stage.addEventListener("drop", (e) => { | |
| if ($("sceneWork").classList.contains("on")) return; | |
| pick(e.dataTransfer.files[0]); | |
| }); | |
| // Dropping outside the stage should not open the PDF in the tab. | |
| window.addEventListener("dragover", (e) => e.preventDefault()); | |
| window.addEventListener("drop", (e) => e.preventDefault()); | |
| // ---- sample PDFs ---- | |
| document.querySelectorAll("#samples [data-size]").forEach((el) => { | |
| el.textContent = `${el.textContent} · ${fmtSize(Number(el.dataset.size))}`; | |
| }); | |
| // Fetch the sample and hand it to pick() exactly like a file chosen from disk. | |
| async function useSample(url, name, btn) { | |
| if (btn) btn.disabled = true; | |
| try { | |
| const res = await fetch(url); | |
| if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); | |
| const blob = await res.blob(); | |
| pick(new File([blob], name, { type: "application/pdf" })); | |
| } catch (err) { | |
| fail(`Couldn't load the sample PDF (${err.message}).`); | |
| } finally { | |
| if (btn) btn.disabled = false; | |
| } | |
| } | |
| const viewer = $("viewer"); | |
| if (viewer) { | |
| let current = null; | |
| document.querySelectorAll("[data-sample-view]").forEach((b) => b.addEventListener("click", () => { | |
| current = b.dataset; | |
| $("viewerTitle").textContent = b.dataset.title; | |
| $("viewerOpen").href = b.dataset.url; | |
| $("viewerFrame").src = b.dataset.url; | |
| viewer.showModal(); | |
| })); | |
| document.querySelectorAll("[data-sample-use]").forEach((b) => b.addEventListener("click", () => useSample(b.dataset.url, b.dataset.file, b))); | |
| $("viewerUse").addEventListener("click", async () => { | |
| viewer.close(); | |
| if (current) await useSample(current.url, current.file); | |
| }); | |
| $("viewerClose").addEventListener("click", () => viewer.close()); | |
| // Click on the backdrop closes the preview. | |
| viewer.addEventListener("click", (e) => { if (e.target === viewer) viewer.close(); }); | |
| viewer.addEventListener("close", () => { $("viewerFrame").removeAttribute("src"); }); | |
| } | |
| // ---- progress ---- | |
| // The server answers once, at the end, so the phases are paced by typical timings | |
| // and the last one waits for the real response. | |
| const PHASES = [0, 8, 22, 70]; // seconds at which each phase starts | |
| function startProgress() { | |
| const items = [...$("phases").children]; | |
| const t0 = Date.now(); | |
| const tick = () => { | |
| const s = (Date.now() - t0) / 1000; | |
| const idx = PHASES.filter((p) => s >= p).length - 1; | |
| items.forEach((li, i) => { | |
| li.classList.toggle("done", i < idx); | |
| li.classList.toggle("active", i === idx); | |
| }); | |
| // Ease toward 92% and hold there until the response arrives. | |
| const pct = Math.min(92, 92 * (1 - Math.exp(-s / 45))); | |
| $("loadfill").style.width = pct + "%"; | |
| $("loadbar").setAttribute("aria-valuenow", Math.round(pct)); | |
| const m = Math.floor(s / 60), sec = String(Math.floor(s % 60)).padStart(2, "0"); | |
| $("elapsed").textContent = `${m}:${sec} · usually 1–3 min`; | |
| }; | |
| tick(); | |
| timer = setInterval(tick, 500); | |
| } | |
| function stopProgress() { clearInterval(timer); timer = null; } | |
| // ---- build ---- | |
| async function build() { | |
| if (!file) return; | |
| show("sceneWork"); | |
| $("stageLabel").textContent = "running…"; | |
| say("Building your project. This usually takes one to three minutes."); | |
| startProgress(); | |
| const body = new FormData(); | |
| body.append("pdf_file", file); | |
| let data = null, ok = false; | |
| try { | |
| const res = await fetch("/process_pdf", { method: "POST", body }); | |
| ok = res.ok; | |
| try { data = await res.json(); } catch (e) { data = { message: `Server returned ${res.status} ${res.statusText}` }; } | |
| } catch (err) { | |
| data = { message: "Couldn't reach the server. Check your connection and try again." }; | |
| } | |
| stopProgress(); | |
| if (ok && data && data.download_url) win(data); | |
| else if (data && data.code === "rate_limited") fail(clean(data.message), data.project_id, true); | |
| else fail(clean((data && (data.error || data.message)) || "Something went wrong."), data && data.project_id); | |
| } | |
| $("buildBtn").addEventListener("click", build); | |
| $("retryBtn").addEventListener("click", () => file ? build() : restart()); | |
| const clean = (s) => String(s).replace(/^[✅❌]\s*/u, ""); | |
| // A sprite's scripts as scratchblocks text; renderBlocks() turns them into Scratch blocks. | |
| function codeSection(scripts, emptyText) { | |
| scripts = Array.isArray(scripts) ? scripts : []; | |
| const sec = document.createElement("div"); | |
| sec.className = "code"; | |
| const head = document.createElement("div"); | |
| head.className = "code-head"; | |
| head.append( | |
| Object.assign(document.createElement("span"), { textContent: "Code blocks" }), | |
| Object.assign(document.createElement("span"), { textContent: scripts.length ? `${scripts.length} script${scripts.length === 1 ? "" : "s"}` : "" }) | |
| ); | |
| sec.append(head); | |
| if (!scripts.length) { | |
| sec.append(Object.assign(document.createElement("p"), { className: "empty", textContent: emptyText })); | |
| return sec; | |
| } | |
| const board = document.createElement("div"); | |
| board.className = "board"; | |
| board.append(...scripts.map((s) => Object.assign(document.createElement("pre"), { className: "blocks", textContent: s }))); | |
| sec.append(board); | |
| return sec; | |
| } | |
| // Without the library (CDN blocked or still loading) the scripts stay readable as text. | |
| function renderBlocks() { | |
| if (!window.scratchblocks) { | |
| $("sbLib").addEventListener("load", renderBlocks, { once: true }); | |
| return; | |
| } | |
| try { | |
| scratchblocks.renderMatching("#matches pre.blocks", { style: "scratch3", languages: ["en"], scale: 0.6 }); | |
| } catch (e) { /* leave the text */ } | |
| } | |
| function win(data) { | |
| $("loadfill").style.width = "100%"; | |
| [...$("phases").children].forEach((li) => { li.classList.remove("active"); li.classList.add("done"); }); | |
| const matches = Array.isArray(data.matches) ? data.matches : []; | |
| const found = matches.filter((m) => m.asset); | |
| $("winSub").textContent = found.length | |
| ? `${found.length} of ${matches.length} figure${matches.length === 1 ? "" : "s"} matched to library assets.` | |
| : matches.length ? "No library assets matched, so it starts on a blank stage." : "Built from the code in your PDF."; | |
| $("downloadBtn").href = data.download_url; | |
| $("stageLabel").textContent = "stage · 480×360"; | |
| const list = $("matches"); | |
| const ARROW = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>'; | |
| // One side of a match card: an image with a caption, or a placeholder when there is none. | |
| const thumb = (src, alt, caption, emptyText) => { | |
| const fig = document.createElement("figure"); | |
| fig.className = "thumb"; | |
| const box = document.createElement("div"); | |
| box.className = "img"; | |
| if (src) { | |
| const img = document.createElement("img"); | |
| img.src = src; img.alt = alt; img.loading = "lazy"; | |
| img.addEventListener("error", () => { box.replaceChildren(Object.assign(document.createElement("span"), { className: "empty", textContent: "image unavailable" })); }); | |
| box.append(img); | |
| } else { | |
| box.append(Object.assign(document.createElement("span"), { className: "empty", textContent: emptyText })); | |
| } | |
| const cap = document.createElement("figcaption"); | |
| cap.textContent = caption; | |
| fig.append(box, cap); | |
| return fig; | |
| }; | |
| list.replaceChildren(...matches.map((m) => { | |
| const label = String(m.figure || "").replace(/^Sprite\s*/i, "figure "); | |
| const li = document.createElement("li"); | |
| li.className = "match"; | |
| const pair = document.createElement("div"); | |
| pair.className = "pair"; | |
| const arrow = document.createElement("span"); | |
| arrow.innerHTML = ARROW; | |
| pair.append( | |
| thumb(m.figure_image, `${label} from your PDF`, "your pdf", "not shown"), | |
| arrow.firstChild, | |
| thumb(m.asset_image_url, m.asset ? `Library asset ${m.asset}` : "No match", "library", "no match") | |
| ); | |
| const asset = document.createElement("div"); | |
| asset.className = "asset" + (m.asset ? "" : " none"); | |
| asset.textContent = m.asset || "No library match"; | |
| asset.title = m.asset || ""; | |
| const meter = document.createElement("div"); | |
| meter.className = "meter"; | |
| const pct = Math.max(0, Math.min(100, Math.round((Number(m.score) || 0) * 100))); | |
| meter.innerHTML = `<b><i style="width:${pct}%"></i></b><em>${pct}%</em>`; | |
| const side = document.createElement("div"); | |
| side.className = "side"; | |
| side.append(pair, asset, meter); | |
| li.append(side, codeSection(m.code, m.asset ? "No code blocks for this sprite" : "No code blocks")); | |
| return li; | |
| })); | |
| // Code the server could not tie to any matched figure still gets shown. | |
| const other = Array.isArray(data.unmatched_code) ? data.unmatched_code : []; | |
| list.append(...other.map((o) => { | |
| const li = document.createElement("li"); | |
| li.className = "match other"; | |
| const title = document.createElement("div"); | |
| title.className = "asset"; | |
| title.textContent = o.name ? `${o.name} · no matched figure` : "Other code"; | |
| li.append(title, codeSection(o.code, "No code blocks")); | |
| return li; | |
| })); | |
| renderBlocks(); | |
| $("matchCount").textContent = matches.length ? `${found.length}/${matches.length}` : ""; | |
| $("matches").hidden = matches.length === 0 && other.length === 0; | |
| $("raw").textContent = JSON.stringify(data, null, 2); | |
| show("sceneDone"); | |
| $("panel").classList.add("on"); | |
| say("Your game is ready. Download the .sb3 file."); | |
| } | |
| // When the model provider is rate limited, offer a sample's ready-made .sb3 so the user can still try a game: | |
| // the one built from the PDF they uploaded if it was a sample, otherwise the first sample that has one. | |
| function readySample() { | |
| const all = [...document.querySelectorAll("[data-sample-use]")].map((b) => b.dataset).filter((d) => d.sb3); | |
| return all.find((d) => file && d.file === file.name) || all[0] || null; | |
| } | |
| function fail(msg, projectId, rateLimited = false) { | |
| stopProgress(); | |
| $("errMsg").textContent = msg; | |
| $("errId").textContent = projectId ? `ref ${projectId}` : ""; | |
| $("retryBtn").hidden = !file; | |
| const sample = rateLimited ? readySample() : null; | |
| $("fallbackBtn").hidden = !sample; | |
| $("fallbackNote").hidden = !sample; | |
| $("retryBtn").className = sample ? "btn btn-ghost" : "btn btn-primary"; | |
| if (sample) { | |
| $("fallbackBtn").href = sample.sb3; | |
| $("fallbackNote").innerHTML = ""; | |
| $("fallbackNote").append( | |
| `Meanwhile, here is the game already built from the “${sample.title}” sample. Open it in `, | |
| Object.assign(document.createElement("a"), { href: "https://scratch.mit.edu/projects/editor/", target: "_blank", rel: "noopener", textContent: "Scratch" }), | |
| ": File → Load from your computer." | |
| ); | |
| } | |
| $("stageLabel").textContent = "stage · 480×360"; | |
| $("panel").classList.remove("on"); | |
| show("sceneError"); | |
| say(msg); | |
| } | |
| function restart() { | |
| file = null; | |
| input.value = ""; | |
| $("panel").classList.remove("on"); | |
| show("sceneIdle"); | |
| } | |
| document.querySelectorAll("[data-restart]").forEach((b) => b.addEventListener("click", restart)); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |