README, docs and site: one story (the whole job, no avoidable rejections, the desktop app, your AI, install)
README is a short front page (about 120 lines) with one real visual per section; every former section moved unchanged into docs/*.md with a docs/README.md index (journey, rejections, getting-started, commands, growth, studios, store-art, listing-languages, api, skills, mcp, safety, faq, project). The site tells the same story on one page: the teaser in the hero, real CLI output as text, the desktop app as a section with light/dark captures of eight screens and the per-system downloads (desktop/latest.json); /desktop/ redirects to /#download. New capture tools: tools/site-desktop-shots.ts (fixture-mode desktop frames) and tools/site-term-shots.ts (real CLI output as text and WebP). tests/docs-links.test.ts checks README, docs and site links resolve.
|
|
@ -4,209 +4,17 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>storebound desktop · download</title>
|
||||
<meta name="description" content="storebound desktop: every app and game on Google Play and the App Store in one window, driving the same storebound CLI. Downloads for macOS, Windows and Linux with checksums and signatures.">
|
||||
<link rel="canonical" href="https://storebound.dev/desktop/">
|
||||
<meta name="robots" content="noindex">
|
||||
<link rel="canonical" href="https://storebound.dev/#desktop">
|
||||
<meta http-equiv="refresh" content="0; url=../#download">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="theme-color" content="#f8fafc" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#0b1120" media="(prefers-color-scheme: dark)">
|
||||
<link rel="icon" href="../favicon.ico?v=2" sizes="16x16 32x32 48x48">
|
||||
<link rel="manifest" href="../manifest.webmanifest?v=2">
|
||||
<link rel="apple-touch-icon" href="../apple-touch-icon.png?v=2">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="storebound">
|
||||
<meta property="og:url" content="https://storebound.dev/desktop/">
|
||||
<meta property="og:title" content="storebound desktop · download">
|
||||
<meta property="og:description" content="Every app and game on both stores in one window. macOS, Windows, Linux.">
|
||||
<meta property="og:image" content="https://storebound.dev/og.png?v=2">
|
||||
<link rel="stylesheet" href="../style.css?v=20261012">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<style>
|
||||
/* page-only parts; colours and type come from ../style.css tokens (fallbacks keep the page readable alone) */
|
||||
.dl-hero { padding: 56px 0 40px; }
|
||||
.dl-hero h1 { font-size: clamp(30px, 5vw, 46px); font-weight: 760; }
|
||||
.dl-main { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 16px; margin-top: 28px; }
|
||||
.dl-main .meta { color: var(--muted, #64748b); font-size: 14.5px; }
|
||||
.dl-notice { margin-top: 22px; max-width: 46em; padding: 14px 16px; border-radius: 12px; border: 1px solid var(--ui-ink, #92400e); background: var(--ui-bg, #fef3c7); color: var(--ui-ink, #92400e); font-size: 15px; }
|
||||
.dl-notice b { font-weight: 700; }
|
||||
.dl-notice ol { margin: 8px 0 0; padding-left: 20px; }
|
||||
.dl-table { width: 100%; border-collapse: collapse; margin-top: 24px; font-size: 15px; }
|
||||
.dl-table th, .dl-table td { text-align: left; padding: 12px 10px; border-bottom: 1px solid var(--line, #e2e8f0); vertical-align: top; }
|
||||
.dl-table th { font: 600 12.5px var(--mono, monospace); text-transform: uppercase; letter-spacing: .08em; color: var(--muted, #64748b); }
|
||||
.dl-table tr.here td { background: var(--accent-soft, #eef2ff); }
|
||||
.dl-table .sha { font: 12.5px/1.5 var(--mono, monospace); color: var(--ink-2, #334155); overflow-wrap: anywhere; display: block; max-width: 34em; }
|
||||
.dl-table .fname { font-weight: 600; overflow-wrap: anywhere; }
|
||||
.dl-table .small { font-size: 13.5px; color: var(--muted, #64748b); }
|
||||
.dl-steps { margin: 16px 0 0; padding-left: 20px; color: var(--ink-2, #334155); max-width: 50em; }
|
||||
.dl-steps li { margin: 6px 0; }
|
||||
.dl-key { display: block; margin-top: 8px; font: 13px/1.6 var(--mono, monospace); overflow-wrap: anywhere; }
|
||||
@media (max-width: 720px) {
|
||||
.dl-table thead { display: none; }
|
||||
.dl-table, .dl-table tbody, .dl-table tr, .dl-table td { display: block; width: 100%; }
|
||||
.dl-table tr { border-bottom: 1px solid var(--line, #e2e8f0); padding: 8px 0; }
|
||||
.dl-table td { border: 0; padding: 4px 0; }
|
||||
}
|
||||
</style>
|
||||
<script>location.replace('../#download')</script>
|
||||
<style>body { font: 16px/1.6 ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 40px 16px; background: #f8fafc; color: #0f172a; } a { color: #4f46e5; } @media (prefers-color-scheme: dark) { body { background: #0b1120; color: #f1f5f9; } a { color: #818cf8; } }</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
|
||||
<header class="top">
|
||||
<div class="wrap top-in">
|
||||
<a class="brand" href="../" aria-label="storebound home">
|
||||
<img class="mark" src="../img/logo-mark.png?v=2" srcset="../img/logo-mark.png?v=2 1x, ../img/logo-mark@2x.png?v=2 2x" width="28" height="28" alt="storebound logo">
|
||||
<span>storebound</span>
|
||||
</a>
|
||||
<nav aria-label="Primary">
|
||||
<a href="../">Home</a>
|
||||
<a href="#all">All downloads</a>
|
||||
<a href="#verify">Verify</a>
|
||||
<a class="nav-git" href="https://git.crt.fyi/CRT/storebound" target="_blank" rel="noopener">git<span class="ext" aria-hidden="true">↗</span><span class="sr-only"> (opens in a new tab)</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<section class="dl-hero">
|
||||
<div class="wrap">
|
||||
<p class="kicker">Desktop app · preview</p>
|
||||
<h1>storebound desktop</h1>
|
||||
<p class="sub">Every app and game you ship, on Google Play and the App Store, in one window: releases on both stores, catalog parity, listings, crash health, reviews and TestFlight. It drives the same open source storebound CLI, keeps store keys in the system keychain, shows a dry run before every change, and never presses Submit.</p>
|
||||
|
||||
<div class="dl-main" id="primary" aria-live="polite">
|
||||
<p class="meta" id="status">Looking up the latest release…</p>
|
||||
</div>
|
||||
<noscript><p class="note">The download list needs JavaScript. Without it: <a href="latest.json">latest.json</a> lists every file with its SHA-256.</p></noscript>
|
||||
<div id="notice"></div>
|
||||
<p class="note">Already use the CLI? <code>storebound desktop</code> opens the app in your app folder, or prints the download for your system; <code>storebound desktop --download</code> saves the installer to ~/Downloads after checking its signature (it never installs anything).</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="sec alt" id="all">
|
||||
<div class="wrap">
|
||||
<p class="kicker">All downloads</p>
|
||||
<h2>Every system, with checksums</h2>
|
||||
<p class="sub" id="all-sub">The app updates itself from this site: each update is signed, and the app checks the signature before it replaces anything. It asks before installing.</p>
|
||||
<table class="dl-table" id="table" hidden>
|
||||
<thead><tr><th scope="col">System</th><th scope="col">File</th><th scope="col">SHA-256</th></tr></thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="sec" id="verify">
|
||||
<div class="wrap">
|
||||
<p class="kicker">Verify</p>
|
||||
<h2>Check a download yourself</h2>
|
||||
<ol class="dl-steps">
|
||||
<li>Checksum: <code>shasum -a 256 <file></code> (macOS, Linux) or <code>Get-FileHash <file></code> (Windows) must print the SHA-256 above.</li>
|
||||
<li>Signature: every file has a <code>.sig</code> next to it (the app's update key, minisign). With <a href="https://jedisct1.github.io/minisign/" target="_blank" rel="noopener">minisign</a>: <code>base64 -d <file>.sig > f.minisig && minisign -Vm <file> -x f.minisig -P <key></code>, with the public key
|
||||
<code class="dl-key">RWQqOO/ZeIr3RBmolt/fx108m89QNEUndRTFrucjx0CBMoeFb7/xhrvW</code></li>
|
||||
<li>Or let the CLI do both: <code>storebound desktop --download</code>.</li>
|
||||
</ol>
|
||||
<p class="note">The npm package <code>storebound</code> stays the CLI, MCP server and agent skills; it does not contain this app. The app brings its own copy of the CLI.</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="foot">
|
||||
<div class="wrap foot-in">
|
||||
<div>
|
||||
<a class="brand" href="../" aria-label="storebound home">
|
||||
<img class="mark" src="../img/logo-mark.png?v=2" srcset="../img/logo-mark.png?v=2 1x, ../img/logo-mark@2x.png?v=2 2x" width="28" height="28" alt="storebound logo">
|
||||
<span>storebound</span>
|
||||
</a>
|
||||
<p class="fine">MIT · made by CRTkafa · not affiliated with Google or Apple.<br>App Store is a trademark of Apple Inc.; Google Play is a trademark of Google LLC.</p>
|
||||
</div>
|
||||
<nav aria-label="Footer">
|
||||
<a href="https://git.crt.fyi/CRT/storebound/src/branch/main/docs/desktop.md" target="_blank" rel="noopener">desktop docs<span class="ext" aria-hidden="true">↗</span><span class="sr-only"> (opens in a new tab)</span></a>
|
||||
<a href="https://git.crt.fyi/CRT/storebound/src/branch/main/CHANGELOG.md" target="_blank" rel="noopener">CHANGELOG<span class="ext" aria-hidden="true">↗</span><span class="sr-only"> (opens in a new tab)</span></a>
|
||||
<a href="https://git.crt.fyi/CRT/storebound/src/branch/main/SECURITY.md" target="_blank" rel="noopener">SECURITY<span class="ext" aria-hidden="true">↗</span><span class="sr-only"> (opens in a new tab)</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Reads latest.json (same origin, published with the binaries) and fills the page. Everything from it is set as
|
||||
// text, never as HTML; only storebound.dev/desktop/dl/ links are shown.
|
||||
(function () {
|
||||
var LABEL = {
|
||||
'macos-universal-dmg': ['macOS 11 or later', 'Apple silicon and Intel, .dmg'],
|
||||
'windows-x86_64-nsis': ['Windows 10 / 11', 'x64 installer, installs for your user only'],
|
||||
'linux-x86_64-appimage': ['Linux', 'x64 AppImage (Ubuntu 22.04+, Debian 12+)'],
|
||||
'linux-x86_64-deb': ['Linux (Debian, Ubuntu)', 'x64 .deb package']
|
||||
};
|
||||
var ORDER = ['macos-universal-dmg', 'windows-x86_64-nsis', 'linux-x86_64-appimage', 'linux-x86_64-deb'];
|
||||
function el(tag, attrs, text) { var e = document.createElement(tag); for (var k in attrs || {}) e.setAttribute(k, attrs[k]); if (text != null) e.textContent = text; return e; }
|
||||
function mb(n) { return (n / 1e6).toFixed(1) + ' MB'; }
|
||||
function ours(u) { try { var x = new URL(u, location.href); return x.protocol === 'https:' && x.host === 'storebound.dev' && x.pathname.indexOf('/desktop/dl/') === 0; } catch (e) { return false; } }
|
||||
function system() {
|
||||
var p = ((navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || '') + ' ' + navigator.userAgent;
|
||||
if (/android|iphone|ipad|ipod/i.test(p)) return 'mobile';
|
||||
if (/mac/i.test(p)) return 'macos-universal-dmg';
|
||||
if (/win/i.test(p)) return 'windows-x86_64-nsis';
|
||||
if (/linux|x11/i.test(p)) return 'linux-x86_64-appimage';
|
||||
return null;
|
||||
}
|
||||
var status = document.getElementById('status');
|
||||
var primary = document.getElementById('primary');
|
||||
var notice = document.getElementById('notice');
|
||||
fetch('latest.json', { cache: 'no-cache' }).then(function (r) {
|
||||
if (r.status === 404) throw new Error('none');
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
}).then(function (m) {
|
||||
var d = m.downloads || {};
|
||||
var keys = ORDER.filter(function (k) { return d[k] && ours(d[k].url); });
|
||||
if (!keys.length) throw new Error('none');
|
||||
var here = system();
|
||||
var pick = d[here] && keys.indexOf(here) >= 0 ? here : null;
|
||||
primary.textContent = '';
|
||||
if (pick) {
|
||||
var a = el('a', { 'class': 'btn btn-primary', href: d[pick].url }, 'Download for ' + LABEL[pick][0]);
|
||||
primary.appendChild(a);
|
||||
} else {
|
||||
primary.appendChild(el('a', { 'class': 'btn btn-primary', href: '#all' }, 'Choose your system'));
|
||||
}
|
||||
var meta = 'Version ' + m.version + (m.pub_date ? ' · ' + m.pub_date.slice(0, 10) : '') + (pick ? ' · ' + mb(d[pick].size) : '') + (here === 'mobile' ? ' · the desktop app runs on macOS, Windows and Linux' : '');
|
||||
primary.appendChild(el('p', { 'class': 'meta' }, meta));
|
||||
var unsigned = keys.filter(function (k) { return !d[k].codeSigned && (k === 'macos-universal-dmg' || k === 'windows-x86_64-nsis'); });
|
||||
if (unsigned.length) {
|
||||
var n = el('div', { 'class': 'dl-notice', role: 'note' });
|
||||
n.appendChild(el('b', null, 'Unsigned preview. '));
|
||||
n.appendChild(document.createTextNode('This early build is not yet signed by Apple or Microsoft, so your system asks once before opening it. Check the SHA-256 below first.'));
|
||||
var ol = el('ol');
|
||||
if (unsigned.indexOf('macos-universal-dmg') >= 0) ol.appendChild(el('li', null, 'macOS: drag storebound to Applications and open it; when macOS refuses, open System Settings > Privacy & Security and choose Open Anyway (once).'));
|
||||
if (unsigned.indexOf('windows-x86_64-nsis') >= 0) ol.appendChild(el('li', null, 'Windows: when SmartScreen shows "Windows protected your PC", choose More info > Run anyway.'));
|
||||
n.appendChild(ol);
|
||||
notice.appendChild(n);
|
||||
}
|
||||
var rows = document.getElementById('rows');
|
||||
keys.forEach(function (k) {
|
||||
var x = d[k];
|
||||
var tr = el('tr', k === pick ? { 'class': 'here' } : {});
|
||||
var sys = el('td');
|
||||
sys.appendChild(el('b', null, LABEL[k][0]));
|
||||
sys.appendChild(el('div', { 'class': 'small' }, LABEL[k][1] + ((k === 'macos-universal-dmg' || k === 'windows-x86_64-nsis') && !x.codeSigned ? ' · unsigned preview' : '')));
|
||||
var file = el('td');
|
||||
file.appendChild(el('a', { 'class': 'fname', href: x.url }, x.file));
|
||||
var sub = el('div', { 'class': 'small' }, mb(x.size) + ' · ');
|
||||
sub.appendChild(el('a', { href: x.url + '.sig' }, 'signature'));
|
||||
file.appendChild(sub);
|
||||
var sha = el('td');
|
||||
sha.appendChild(el('code', { 'class': 'sha' }, x.sha256));
|
||||
tr.appendChild(sys); tr.appendChild(file); tr.appendChild(sha);
|
||||
rows.appendChild(tr);
|
||||
});
|
||||
document.getElementById('table').hidden = false;
|
||||
}).catch(function (e) {
|
||||
var none = e && e.message === 'none';
|
||||
status.textContent = none
|
||||
? 'The first desktop release is not published yet. Until then the storebound CLI does everything the app will show.'
|
||||
: 'Could not read the release list (' + (e && e.message) + '). latest.json has every file and its SHA-256.';
|
||||
if (none) primary.appendChild(el('a', { 'class': 'btn btn-ghost', href: '../#start' }, 'Get the CLI'));
|
||||
else primary.appendChild(el('a', { 'class': 'btn btn-ghost', href: 'latest.json' }, 'latest.json'));
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<!-- The desktop app is a section of the home page now; this URL stays because the CLI (cli/lib/desktop-dist.ts PAGE)
|
||||
and older links point here. latest.json and dl/ next to it are served by the release server, not this file. -->
|
||||
<p>The desktop app moved to the home page: <a href="../#download">storebound.dev/#download</a>.</p>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
BIN
site/img/desktop/catalog-dark.webp
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
site/img/desktop/catalog-light.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
site/img/desktop/checks-dark.webp
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
site/img/desktop/checks-light.webp
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
site/img/desktop/console-plan-dark.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
site/img/desktop/console-plan-light.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
site/img/desktop/growth-dark.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
site/img/desktop/growth-light.webp
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
site/img/desktop/portfolio-dark.webp
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
site/img/desktop/portfolio-light.webp
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
site/img/desktop/prices-dark.webp
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
site/img/desktop/prices-light.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
site/img/desktop/send-for-review-dark.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
site/img/desktop/send-for-review-light.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
site/img/desktop/testflight-dark.webp
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
site/img/desktop/testflight-light.webp
Normal file
|
After Width: | Height: | Size: 50 KiB |
15
site/img/term/check.txt
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
$ storebound check
|
||||
[check] config ok: storebound.config.ts
|
||||
[check] play listing: 2 languages
|
||||
[check] hint: the App Store name is "Example App" in every locale: App Store names are unique, so if it is taken App Store Connect refuses the record ("already being used"). Then set apple.name with a short suffix, e.g. "Example App: <tagline>" (30 characters at most); app.name stays the Play and on-device name
|
||||
[check] appstore listing: 2 locales
|
||||
[check] data safety: 768 CSV rows
|
||||
[check] shots copy: 2 languages x 4 scenes
|
||||
[check] hint: Play App category "Productivity" (apple.info.categories (PRODUCTIVITY)) is a console step, not an API one: `storebound console plan play-store-settings`
|
||||
[check] WARN 31 Play App content answer(s) are not in the config: ask the owner in one batch at the start (`storebound console plan play-app-content` lists them), then write them into the config
|
||||
- App Review needs a demo account (sign-in required) · add reviewDemoAccount to .storebound/credentials.json · review-access skill
|
||||
- missing file: store/icon/icon.png
|
||||
- missing file: store/icon/foreground.png
|
||||
- missing file: store/icon/background.png
|
||||
|
||||
error: 4 problems
|
||||
BIN
site/img/term/check.webp
Normal file
|
After Width: | Height: | Size: 60 KiB |
160
site/img/term/console-plan.txt
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
$ storebound console plan play-content-rating
|
||||
Play Console: Content rating (IARC)
|
||||
start: https://play.google.com/console
|
||||
then: Pick the developer account if asked (set play.developerId for a deep link)
|
||||
then: the app (com.example.prismpuddle) in the app list
|
||||
then: Policy and programs > App content
|
||||
then: Content rating > Start (or Manage)
|
||||
|
||||
Steps:
|
||||
1. Open App content > Content ratings (/app-content/content-rating-overview), then "Start questionnaire": it opens /app-content/content-rating-iarc-questionnaire. [click: "Start questionnaire"]
|
||||
2. Page 1: type the email address and pick the category (material radio: JS click on the inner [role=radio]). The IARC Terms of Use checkbox is an agreement: never tick it; the owner ticks it (it is in the batch). Then Next. [click: "Next"]
|
||||
3. The questionnaire is ONE long page of sections (Violence, Fear, Sexuality, Gambling, Language, Controlled Substance, Crude Humor, Digital Purchases, Miscellaneous). Answer each section's Yes / No as the sheet says; each Yes reveals follow-ups right below it, so answer top to bottom and re-read the page after every Yes. Run it as one in-page job (plan.inPageLoop) and poll window.__log.
|
||||
4. Violence follow-ups (after Yes): the humans / non-humans checkboxes, then setting, childlike style, reactions, presentation checkboxes, blood level, historical war setting, innocent characters, fierce sounds.
|
||||
5. Fear, Sexuality, Gambling, Language, Controlled Substance, Crude Humor follow-ups (after Yes): answer the questions shown as the sheet says (details.<section>.other).
|
||||
6. Digital Purchases follow-ups (after "purchases of digital goods" Yes): the random items (loot box) question, then trading between players.
|
||||
7. Miscellaneous: user interaction (after Yes: block, report, moderation, friends only), location sharing, Nazi symbols, Korea history, terrorism, crimes.
|
||||
8. Press Save (stores the draft; nothing goes to IARC yet), then Next: the Summary shows the rating per authority (PEGI, ESRB, USK, ClassInd, Google Play ...). [click: "Save", "Next"]
|
||||
9. Summary: stop. Do NOT press its Save (the final button here is "Save", not "Submit"): tell the owner the ratings shown and let them press Save (it applies the IARC ratings).
|
||||
10. Verify: read the Summary page as text and pipe it to `storebound console verify play-content-rating --from-text -`.
|
||||
|
||||
Answer sheet:
|
||||
[ok] Email address: s***@example.com -> type "Email address" @ Email address and category
|
||||
[ask] Category: (you decide) -> pick "Category" @ Email address and category
|
||||
Pick the category; set console.contentRating.category to have it filled.
|
||||
[you] IARC Terms of Use: the owner ticks it (an agreement) -> untick "IARC Terms of Use" @ Email address and category
|
||||
An agreement on page 1 (a checkbox): only the owner ticks it, never the agent or storebound.
|
||||
[ask] Violence: (you decide) -> pick "Does the app contain (any )violen" @ Questionnaire (Does the app contain (any )violen)
|
||||
Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.violence.humans, violence.nonHumans, violence.setting, violence.childlike, violence.reactions, violence.presentation, violence.blood, violence.historicalWar, violence.innocents, violence.fierceSounds too.
|
||||
[ask] Fear / horror: (you decide) -> pick "Does the app contain …scary" @ Questionnaire (Does the app contain …scary)
|
||||
Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.fear.other too.
|
||||
[ask] Sexuality or nudity: (you decide) -> pick "Does the app contain …sexual" @ Questionnaire (Does the app contain …sexual)
|
||||
Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.sexuality.other too.
|
||||
[ask] Language (profanity): (you decide) -> pick "Does the app contain …profan" @ Questionnaire (Does the app contain …profan)
|
||||
Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.language.other too.
|
||||
[ask] Controlled substances (drugs, alcohol, tobacco): (you decide) -> pick "Does the app contain …drug" @ Questionnaire (Does the app contain …drug)
|
||||
Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.controlledSubstances.other too.
|
||||
[ask] Crude humour: (you decide) -> pick "crude humour" @ Questionnaire (crude humour)
|
||||
Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.crudeHumor.other too.
|
||||
[ask] Gambling with real money: (you decide) -> pick "Does the app allow …gambl…real money" @ Questionnaire (Does the app allow …gambl…real money)
|
||||
Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.gambling.other too.
|
||||
[ask] Simulated gambling: (you decide) -> pick "simulated gambling" @ Questionnaire (simulated gambling)
|
||||
Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.gambling.other too.
|
||||
[ok] Random paid items (loot boxes): No -> pick "No" @ Questionnaire (random (paid )items)
|
||||
[ask] Users can interact or exchange content: (you decide) -> pick "users …interact" @ Questionnaire (users …interact)
|
||||
Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.userInteraction.block, userInteraction.report, userInteraction.moderation, userInteraction.friendsOnly too.
|
||||
[ask] Shares the user's location with other users: (you decide) -> pick "share …(physical )location" @ Questionnaire (share …(physical )location)
|
||||
Answer from the app's real content; set console.contentRating.answers to have it filled.
|
||||
[ask] Digital purchases: (you decide) -> pick "digital goods" @ Questionnaire (digital goods)
|
||||
Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.digitalPurchases.playerTrading too.
|
||||
[ask] Unrestricted internet access (browser): (you decide) -> pick "unrestricted internet" @ Questionnaire (unrestricted internet)
|
||||
Answer from the app's real content; set console.contentRating.answers to have it filled.
|
||||
[ask] Miscellaneous: Nazi symbols or references?: (you decide) -> pick "nazi" @ Questionnaire: Miscellaneous follow-ups (nazi)
|
||||
Answer from the app's real content; set console.contentRating.details.miscellaneous.naziSymbols to have it filled.
|
||||
[ask] Miscellaneous: content about Korean history that may be sensitive?: (you decide) -> pick "korea" @ Questionnaire: Miscellaneous follow-ups (korea)
|
||||
Answer from the app's real content; set console.contentRating.details.miscellaneous.koreaHistory to have it filled.
|
||||
[ask] Miscellaneous: terrorism or extremist content?: (you decide) -> pick "terror" @ Questionnaire: Miscellaneous follow-ups (terror)
|
||||
Answer from the app's real content; set console.contentRating.details.miscellaneous.terrorism to have it filled.
|
||||
[ask] Miscellaneous: depicts or encourages crimes?: (you decide) -> pick "crime" @ Questionnaire: Miscellaneous follow-ups (crime)
|
||||
Answer from the app's real content; set console.contentRating.details.miscellaneous.crimes to have it filled.
|
||||
|
||||
Never press: "IARC Terms of Use box", "Save (Summary page)", "Publish", "Submit", "Create", "Create app", "Send", "Release", "Definitions and Examples", "?", "More information"
|
||||
Final button (the owner's): Save (Summary page)
|
||||
Rule: Press only navigation (Next, Back, Get Started, Edit, Set Up, Start / Manage links) and draft saves (Save draft / Save (questionnaire page)). Never press Save (Summary page) or anything that publishes, submits, creates, sends or accepts; stop and tell the owner.
|
||||
|
||||
UI facts:
|
||||
- Verified 2026-10-12 (an agent with a browser tool on a real game):
|
||||
- App content > Content ratings is /app-content/content-rating-overview; "Start questionnaire" opens /app-content/content-rating-iarc-questionnaire.
|
||||
- Page 1: email address, the category radio, and an IARC Terms of Use checkbox: an agreement, the owner ticks it, never the agent.
|
||||
- The questionnaire is one long page of sections; each Yes reveals follow-ups (Violence: humans / non-humans checkboxes, then setting, childlike style, reactions, presentation checkboxes, blood level, historical war setting, innocent characters, fierce sounds; Digital Purchases: digital goods, then loot boxes, then player trading; Miscellaneous: user interaction then block / report / moderation / friends-only, location sharing, Nazi symbols, Korea history, terrorism, crimes). Follow-up wording here is storebound's: match by topic and the console's own labels.
|
||||
- Save on the questionnaire stores it; Next goes to the Summary with per-authority ratings. The Summary's final button is "Save" (not "Submit"): the owner's.
|
||||
- Example result (the game of that run): PEGI 7, ESRB E10+, USK 12, ClassInd 18 (paid random items alone made it 18), Google Play 7+.
|
||||
- The email address field gets the full app.contactEmail from the config (printed masked here).
|
||||
- Play Console radios and checkboxes are <material-radio> / <material-checkbox>: computer-tool clicks and setting the native input (form_input) can leave the Angular model unchanged. Click the inner [role=radio] / [role=checkbox] (or the <input> itself) with a JS el.click(), then read aria-checked (or input.checked). Their labels sit in sibling nodes, not in an aria-label: walk up from the control to the nearest parent whose innerText is not empty and match by "starts with" (verified 2026-10-12).
|
||||
- An app's Play Console home is /console/u/0/developers/<developerId>/app/<appId>/app-dashboard (verified 2026-10-12); with play.appId (or the id storebound remembered) the plan starts there.
|
||||
|
||||
Driving:
|
||||
- Use your own browser tool (Claude in Chrome, a built-in browser, Playwright). Read the page as text or an accessibility tree first; click by visible text; adapt when the page differs from these steps (the console changes without notice), but never press anything in buttons.neverPress.
|
||||
- Long flows run INSIDE the page: one Runtime.evaluate / javascript call times out at about 45 s on big Play pages (the IARC questionnaire, App content), so never loop over many controls in one call. Paste plan.inPageLoop once per page (one short call; it defines window.__sb), start the job with window.__sb.run([{ role: "radio", label: "No", within: "<question text>" }, { role: "checkbox", label: "<box label>", want: true }, ...]) (it returns at once), then poll window.__log.slice(-20) and window.__done in short calls every 2-5 s until the log says done. The helper only picks radios and ticks boxes (never agreements); press Next / Save yourself afterwards, by their visible text.
|
||||
- Wait for each step to render (poll for the next heading or input, up to ~10 s) before acting; never act on a step you have not read.
|
||||
- An answer with status ask-owner: ask the owner in chat before filling it (AGENTS.md rule 5), or leave it empty and tell the owner. Never guess.
|
||||
- Stop before the final button (buttons.final) and tell the owner exactly what to press; then run the verify command.
|
||||
- Never sign in, type a password, accept an agreement or tick a declaration: the owner does that in the browser.
|
||||
|
||||
In-page helper (plan.inPageLoop: paste it once per page in one short call, then window.__sb.run([...]) and poll window.__log):
|
||||
(() => {
|
||||
const norm = (t) => (t || "").replace(/\s+/g, " ").trim();
|
||||
const log = (m) => { window.__log.push(new Date().toISOString().slice(11, 19) + " " + m); };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const labelOf = (el) => {
|
||||
const own = norm(el.getAttribute("aria-label"));
|
||||
if (own) return own;
|
||||
for (let a = el; a && a !== document.body; a = a.parentElement) { const t = norm(a.innerText); if (t) return t; }
|
||||
return "";
|
||||
};
|
||||
const NEVER = /terms of use|terms of service|i agree|agree to|declaration|developer program polic|export laws/i;
|
||||
const CONTROLS = "[role=radio], [role=checkbox], input[type=radio], input[type=checkbox]";
|
||||
const scopeOf = (text) => {
|
||||
const want = norm(text).toLowerCase(); let best = null;
|
||||
for (const el of document.querySelectorAll("section, fieldset, div, [role=radiogroup], [role=group]"))
|
||||
if (norm(el.innerText).toLowerCase().includes(want) && el.querySelector(CONTROLS) && (!best || best.contains(el))) best = el;
|
||||
return best;
|
||||
};
|
||||
const find = (role, label, scope) => {
|
||||
const want = norm(label).toLowerCase();
|
||||
const hits = [...(scope || document).querySelectorAll("[role=" + role + "], input[type=" + role + "]")]
|
||||
.filter((el) => el.getClientRects().length && labelOf(el).toLowerCase().startsWith(want));
|
||||
return hits.length === 1 ? hits[0] : null;
|
||||
};
|
||||
const set = async (s) => {
|
||||
const scope = s.within ? scopeOf(s.within) : null;
|
||||
if (s.within && !scope) return "no block with \"" + s.within + "\"";
|
||||
const el = find(s.role, s.label, scope);
|
||||
if (!el) return "not found or ambiguous";
|
||||
if (NEVER.test(labelOf(el))) return "refused: an agreement (the owner ticks it)";
|
||||
const host = el.closest("material-radio, material-checkbox");
|
||||
const inner = el.matches("[role=radio], [role=checkbox]") ? el : (host && host.querySelector("[role=radio], [role=checkbox]")) || el;
|
||||
const state = () => (inner.hasAttribute("aria-checked") ? inner.getAttribute("aria-checked") === "true" : !!inner.checked);
|
||||
const want = s.want !== false;
|
||||
if (state() !== want) inner.click();
|
||||
await sleep(150);
|
||||
return state() === want ? "ok" : "did not change (read the control, try the <input> itself)";
|
||||
};
|
||||
window.__log = []; window.__done = false;
|
||||
window.__sb = { log, sleep, labelOf, scopeOf, find, set };
|
||||
window.__sb.run = (steps) => {
|
||||
window.__done = false;
|
||||
window.__run = (async () => {
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const s = steps[i], tag = (i + 1) + "/" + steps.length + " " + s.role + " \"" + s.label + "\"" + (s.within ? " in \"" + s.within + "\"" : "");
|
||||
try { log(tag + ": " + await set(s)); } catch (e) { log(tag + ": error " + e.message); }
|
||||
}
|
||||
log("done"); window.__done = true;
|
||||
})();
|
||||
return "started " + steps.length + " steps: poll window.__log";
|
||||
};
|
||||
return "storebound helper ready: window.__sb.run([...]), then poll window.__log.slice(-20) and window.__done";
|
||||
})()
|
||||
|
||||
Verify: storebound console verify play-content-rating --from-text -
|
||||
- The Summary shows each question with the Yes / No of the answer sheet, and a rating per authority.
|
||||
|
||||
Ask the owner first:
|
||||
- Category: ask the owner (Pick the category; set console.contentRating.category to have it filled.)
|
||||
- IARC Terms of Use: an agreement, the owner ticks it
|
||||
- Violence: ask the owner (Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.violence.humans, violence.nonHumans, violence.setting, violence.childlike, violence.reactions, violence.presentation, violence.blood, violence.historicalWar, violence.innocents, violence.fierceSounds too.)
|
||||
- Fear / horror: ask the owner (Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.fear.other too.)
|
||||
- Sexuality or nudity: ask the owner (Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.sexuality.other too.)
|
||||
- Language (profanity): ask the owner (Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.language.other too.)
|
||||
- Controlled substances (drugs, alcohol, tobacco): ask the owner (Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.controlledSubstances.other too.)
|
||||
- Crude humour: ask the owner (Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.crudeHumor.other too.)
|
||||
- Gambling with real money: ask the owner (Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.gambling.other too.)
|
||||
- Simulated gambling: ask the owner (Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.gambling.other too.)
|
||||
- Users can interact or exchange content: ask the owner (Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.userInteraction.block, userInteraction.report, userInteraction.moderation, userInteraction.friendsOnly too.)
|
||||
- Shares the user's location with other users: ask the owner (Answer from the app's real content; set console.contentRating.answers to have it filled.)
|
||||
- Digital purchases: ask the owner (Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.digitalPurchases.playerTrading too.)
|
||||
- Unrestricted internet access (browser): ask the owner (Answer from the app's real content; set console.contentRating.answers to have it filled.)
|
||||
- Miscellaneous: Nazi symbols or references?: ask the owner (Answer from the app's real content; set console.contentRating.details.miscellaneous.naziSymbols to have it filled.)
|
||||
- Miscellaneous: content about Korean history that may be sensitive?: ask the owner (Answer from the app's real content; set console.contentRating.details.miscellaneous.koreaHistory to have it filled.)
|
||||
- Miscellaneous: terrorism or extremist content?: ask the owner (Answer from the app's real content; set console.contentRating.details.miscellaneous.terrorism to have it filled.)
|
||||
- Miscellaneous: depicts or encourages crimes?: ask the owner (Answer from the app's real content; set console.contentRating.details.miscellaneous.crimes to have it filled.)
|
||||
BIN
site/img/term/console-plan.webp
Normal file
|
After Width: | Height: | Size: 115 KiB |
10
site/img/term/products.txt
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
$ storebound play products --dry
|
||||
[play] 1/2 com.example.app.no_ads: would create · 174 regions · 2 listings (dry)
|
||||
[play] 2/2 com.example.app.gems_100: would create · 174 regions · 2 listings (dry)
|
||||
dry run: play products: 2 change(s); nothing written
|
||||
item action detail
|
||||
------------------------ ------ ---------------------------------
|
||||
com.example.app.no_ads create 174 regions, 2 listings, USD 2.99
|
||||
com.example.app.gems_100 create 174 regions, 2 listings, USD 0.99
|
||||
[play] not on Google Play: com.example.app.no_ads, com.example.app.gems_100. If you created them in the last minutes, the store's list can lag a minute after creating; re-run before acting on this plan
|
||||
[play] products checked (dry run, nothing written)
|
||||
BIN
site/img/term/products.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
59
site/img/term/release-plan.txt
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
$ storebound release plan 1.0.0
|
||||
[release] Example App: validate (read-only, both stores)
|
||||
Example App 1.0.0
|
||||
both stores (fix first):
|
||||
1. CONFIG App Review needs a demo account (sign-in required) · add reviewDemoAccount to .storebound/credentials.json · review-access skill
|
||||
-> storebound check
|
||||
2. CONFIG missing file: store/icon/icon.png
|
||||
-> storebound check
|
||||
3. CONFIG missing file: store/icon/foreground.png
|
||||
-> storebound check
|
||||
4. CONFIG missing file: store/icon/background.png
|
||||
-> storebound check
|
||||
App Store: not on the store
|
||||
5. STORE 1.0.0 is not on App Store yet: upload a build (Xcode / Transporter), then create the version with its texts
|
||||
-> storebound apple next-build; storebound apple listing --version 1.0.0 --build <N> --dry
|
||||
6. BLOCKER no App Store version being prepared
|
||||
-> storebound apple listing --version x.y.z
|
||||
7. BLOCKER no build attached to the version
|
||||
-> storebound apple listing --version x.y.z --build N (pick the build number you just uploaded)
|
||||
8. BLOCKER en-US: no localization on the version
|
||||
-> storebound apple listing
|
||||
9. BLOCKER tr: no localization on the version
|
||||
-> storebound apple listing
|
||||
10. BLOCKER no App Review information
|
||||
-> storebound apple review-details
|
||||
11. BLOCKER age rating declaration not readable / missing
|
||||
-> storebound apple appinfo
|
||||
12. BLOCKER no app price schedule
|
||||
-> storebound apple appinfo
|
||||
13. BLOCKER sign-in required for review but no demo account in .storebound/credentials.json (reviewDemoAccount) or REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD
|
||||
-> review-access skill
|
||||
14. BLOCKER 2.1(a): Sign-in is required but there is no demo account for App Review (storebound.config.ts:35)
|
||||
-> Create a demo account that sees every feature (not a sign-up the reviewer must do), put it in .storebound/credentials.json as reviewDemoAccount (never in the repo), then `storebound apple review-details`. · review-access skill
|
||||
15. warn 2.1(a): Placeholder or template text in the store listing (store/appstore.md:20)
|
||||
-> Replace the template lines and any lorem ipsum / TODO with the real copy before you push the listing. · aso-basics skill
|
||||
Google Play: not on the store
|
||||
16. STORE 1.0.0 is not on Google Play yet: upload the AAB to a testing track
|
||||
-> storebound play next-version-code; storebound play release <aab> --internal completed --dry
|
||||
17. BLOCKER no listing for the default language en-US
|
||||
-> storebound play listing
|
||||
18. BLOCKER en-US: no listing
|
||||
-> storebound play listing
|
||||
19. BLOCKER tr-TR: no listing
|
||||
-> storebound play listing
|
||||
20. BLOCKER no release on any track
|
||||
-> storebound play release <aab>
|
||||
21. BLOCKER sign-in required for review but no demo account in .storebound/credentials.json (reviewDemoAccount) or REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD
|
||||
-> review-access skill
|
||||
22. BLOCKER Play: Package name: Package name com.example.app uses the reserved com.example prefix (storebound.config.ts:46)
|
||||
-> Pick your own reverse-domain package name (it can never change after the first upload) and set it in the Gradle applicationId and play.packageName. · play-first-release skill
|
||||
23. warn production has 0 countries / regions (the API cannot set them)
|
||||
-> storebound console plan play-production-countries
|
||||
24. warn sign-in required: Play Console > App content > App access must list the demo credentials and steps (no API)
|
||||
-> review-access skill
|
||||
25. warn Play: App access: Sign-in is required but there are no reviewer credentials for Play App access (storebound.config.ts:35)
|
||||
-> Play Console > App content > App access: add the demo account and instructions (keep the credentials in .storebound/credentials.json only). · review-access skill
|
||||
26. warn Play: Metadata: Placeholder or template text in the store listing (store/listing.md:23)
|
||||
-> Replace the template lines and any lorem ipsum / TODO with the real copy before you push the listing. · aso-basics skill
|
||||
|
||||
BIN
site/img/term/release-plan.webp
Normal file
|
After Width: | Height: | Size: 107 KiB |
37
site/img/term/scan.txt
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
$ storebound scan
|
||||
storebound scan · storebound.config.ts
|
||||
stack: iOS (native) · targets: iOS · 11 files
|
||||
|
||||
SDKs
|
||||
Google AdMob ads, tracking-capable · CocoaPods Google-Mobile-Ads-SDK 11.10.0 (Podfile.lock:2)
|
||||
Google Sign-In auth · CocoaPods GoogleSignIn 7.1.0 (Podfile.lock:3)
|
||||
|
||||
Rejection risks: 2 blocker, 3 high, 0 medium, 0 low
|
||||
1. BLOCKER 2.1(a) · App Store · Sign-in is required but there is no demo account for App Review
|
||||
at storebound.config.ts:12 reviewAccess: { signInRequired: true, instructions: 'Sign in with the demo account.' },
|
||||
at storebound.config.ts:11 accounts: { enabled: true },
|
||||
at Podfile.lock:3 Google Sign-In: GoogleSignIn 7.1.0
|
||||
fix: Create a demo account that sees every feature (not a sign-up the reviewer must do), put it in .storebound/credentials.json as reviewDemoAccount (never in the repo), then `storebound apple review-details`.
|
||||
skill: review-access · https://developer.apple.com/app-store/review/guidelines/
|
||||
2. BLOCKER 5.1.1(v) · App Store · Accounts can be created but no in-app account deletion was found
|
||||
at storebound.config.ts:11 accounts: { enabled: true },
|
||||
at Planner/LoginView.swift:11 Button("Create account") { Task { try await AccountApi.signUp() } }
|
||||
at Planner/LoginView.swift:17 static func signUp() async throws {
|
||||
fix: Add "Delete account" in the app (it deletes the account and its data, not only signs out; with Sign in with Apple also revoke the token), then set accounts.inApp to where it starts, e.g. "Settings > Account > Delete account".
|
||||
skill: account-deletion · https://developer.apple.com/app-store/review/guidelines/
|
||||
3. HIGH 4.8 · App Store · Third-party login (Google / Facebook / ...) without Sign in with Apple or another equivalent login option
|
||||
at Podfile.lock:3 Google Sign-In: GoogleSignIn 7.1.0
|
||||
at Planner/LoginView.swift:9 GIDSignIn.sharedInstance.signIn(withPresenting: rootViewController()) { _, _ in }
|
||||
fix: Offer Sign in with Apple (Signing & Capabilities > Sign in with Apple, ASAuthorizationAppleIDProvider) or another login that limits data to name and email, lets people hide their email and does not track. Not needed when the app only uses your own account system, or for the exemptions in 4.8 (education / enterprise accounts, government ID, a client for that one service).
|
||||
skill: review-rejections · https://developer.apple.com/app-store/review/guidelines/
|
||||
4. HIGH 3.1.1 · App Store · Restorable purchases (non-consumables or subscriptions) but no Restore Purchases path was found
|
||||
at storebound.config.ts:16 com.example.planner.pro (non_consumable)
|
||||
fix: Add a visible "Restore Purchases" button (StoreKit 2: AppStore.sync(); StoreKit 1: restoreCompletedTransactions; RevenueCat: restorePurchases) on the store / paywall screen or in Settings.
|
||||
skill: store-iap-setup · https://developer.apple.com/app-store/review/guidelines/
|
||||
5. HIGH 5.1.2(i) · App Store · Tracking-capable code without App Tracking Transparency (Google AdMob, IDFA)
|
||||
at Podfile.lock:2 Google AdMob: Google-Mobile-Ads-SDK 11.10.0
|
||||
at Planner/Ads.swift:7 print(ASIdentifierManager.shared().advertisingIdentifier)
|
||||
fix: If any SDK tracks (personalized ads, attribution, IDFA): add NSUserTrackingUsageDescription and request ATT before the SDKs start, and answer App Privacy "used for tracking". If nothing tracks: configure the ad SDKs for non-personalized ads and answer "no tracking".
|
||||
skill: ads-mediation-checks · https://developer.apple.com/app-store/review/guidelines/
|
||||
|
||||
… 52 more lines: the declarations proposal (Data safety, App Privacy, ATT, privacy manifest) and the config diff
|
||||
BIN
site/img/term/scan.webp
Normal file
|
After Width: | Height: | Size: 112 KiB |
984
site/index.html
87
site/main.js
|
|
@ -1,4 +1,5 @@
|
|||
// storebound.dev: copy buttons, AI tool tabs, the hero demo (real CLI output) and the How it works stepper. No network, no storage.
|
||||
// storebound.dev: copy buttons, tabs (AI tools, desktop screens), the teaser player, the demo (real CLI output), the
|
||||
// How it works stepper and the desktop downloads (reads desktop/latest.json, same origin). No storage, no third parties.
|
||||
(() => {
|
||||
const copyText = async text => {
|
||||
try { await navigator.clipboard.writeText(text); return true; } catch {}
|
||||
|
|
@ -268,4 +269,88 @@
|
|||
wide.addEventListener('change', layout);
|
||||
layout();
|
||||
}
|
||||
// Desktop downloads: desktop/latest.json (same origin, published with the binaries by tools/desktop-release.ts) fills
|
||||
// the per-system buttons and the checksum table. Everything from it is set as text, never as HTML; only
|
||||
// storebound.dev/desktop/dl/ links are used. Before the first release the buttons stay off and say so.
|
||||
const dl = document.getElementById('download');
|
||||
if (dl) {
|
||||
const LABEL = {
|
||||
'macos-universal-dmg': ['macOS 11 or later', 'Apple silicon and Intel, .dmg'],
|
||||
'windows-x86_64-nsis': ['Windows 10 / 11', 'x64 installer, installs for your user only'],
|
||||
'linux-x86_64-appimage': ['Linux', 'x64 AppImage (Ubuntu 22.04+, Debian 12+)'],
|
||||
'linux-x86_64-deb': ['Linux (Debian, Ubuntu)', 'x64 .deb package'],
|
||||
};
|
||||
const ORDER = Object.keys(LABEL);
|
||||
const el = (tag, attrs, text) => { const e = document.createElement(tag); for (const k in attrs || {}) e.setAttribute(k, attrs[k]); if (text != null) e.textContent = text; return e; };
|
||||
const mb = n => (n / 1e6).toFixed(1) + ' MB';
|
||||
const ours = u => { try { const x = new URL(u, location.href); return x.protocol === 'https:' && x.host === 'storebound.dev' && x.pathname.startsWith('/desktop/dl/'); } catch { return false; } };
|
||||
const system = () => {
|
||||
const p = ((navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || '') + ' ' + navigator.userAgent;
|
||||
if (/android|iphone|ipad|ipod/i.test(p)) return 'mobile';
|
||||
if (/mac/i.test(p)) return 'macos-universal-dmg';
|
||||
if (/win/i.test(p)) return 'windows-x86_64-nsis';
|
||||
if (/linux|x11/i.test(p)) return 'linux-x86_64-appimage';
|
||||
return null;
|
||||
};
|
||||
const status = document.getElementById('dl-status');
|
||||
const notice = document.getElementById('dl-notice');
|
||||
const buttons = [...dl.querySelectorAll('[data-dl]')];
|
||||
const off = text => {
|
||||
status.textContent = text;
|
||||
for (const b of buttons) b.querySelector('small').textContent += ' · coming soon';
|
||||
};
|
||||
fetch('desktop/latest.json', { cache: 'no-cache' }).then(r => {
|
||||
if (r.status === 404) throw new Error('none');
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
}).then(m => {
|
||||
const d = m.downloads || {};
|
||||
const keys = ORDER.filter(k => d[k] && ours(d[k].url));
|
||||
if (!keys.length) throw new Error('none');
|
||||
const here = system();
|
||||
status.textContent = 'Version ' + m.version + (m.pub_date ? ' · ' + m.pub_date.slice(0, 10) : '') + (here === 'mobile' ? ' · the desktop app runs on macOS, Windows and Linux' : '');
|
||||
for (const b of buttons) {
|
||||
const k = b.dataset.dl, x = d[k];
|
||||
if (!x || !keys.includes(k)) { b.querySelector('small').textContent += ' · not in this release'; continue; }
|
||||
b.href = x.url;
|
||||
b.classList.remove('is-off');
|
||||
b.removeAttribute('aria-disabled');
|
||||
b.querySelector('small').textContent += ' · ' + mb(x.size) + (k === here ? ' · your system' : '');
|
||||
if (k === here) { b.classList.add('is-here', 'btn-primary'); b.classList.remove('btn-ghost'); }
|
||||
}
|
||||
const unsigned = keys.filter(k => !d[k].codeSigned && (k === 'macos-universal-dmg' || k === 'windows-x86_64-nsis'));
|
||||
if (unsigned.length) {
|
||||
const n = el('div', { class: 'dl-notice', role: 'note' });
|
||||
n.appendChild(el('b', null, 'Unsigned preview. '));
|
||||
n.appendChild(document.createTextNode('This early build is not yet signed by Apple or Microsoft, so your system asks once before opening it. Check the SHA-256 under "Checksums and signatures" first.'));
|
||||
const ol = el('ol');
|
||||
if (unsigned.includes('macos-universal-dmg')) ol.appendChild(el('li', null, 'macOS: drag storebound to Applications and open it; when macOS refuses, open System Settings > Privacy & Security and choose Open Anyway (once).'));
|
||||
if (unsigned.includes('windows-x86_64-nsis')) ol.appendChild(el('li', null, 'Windows: when SmartScreen shows "Windows protected your PC", choose More info > Run anyway.'));
|
||||
n.appendChild(ol);
|
||||
notice.appendChild(n);
|
||||
}
|
||||
const rows = document.getElementById('dl-rows');
|
||||
for (const k of keys) {
|
||||
const x = d[k];
|
||||
const tr = el('tr');
|
||||
const sys = el('td');
|
||||
sys.appendChild(el('b', null, LABEL[k][0]));
|
||||
sys.appendChild(el('div', { class: 'small' }, LABEL[k][1] + ((k === 'macos-universal-dmg' || k === 'windows-x86_64-nsis') && !x.codeSigned ? ' · unsigned preview' : '')));
|
||||
const file = el('td');
|
||||
file.appendChild(el('a', { class: 'fname', href: x.url }, x.file));
|
||||
const sub = el('div', { class: 'small' }, mb(x.size) + ' · ');
|
||||
sub.appendChild(el('a', { href: x.url + '.sig' }, 'signature'));
|
||||
file.appendChild(sub);
|
||||
const sha = el('td');
|
||||
sha.appendChild(el('code', { class: 'sha' }, x.sha256));
|
||||
tr.append(sys, file, sha);
|
||||
rows.appendChild(tr);
|
||||
}
|
||||
document.getElementById('dl-table').hidden = false;
|
||||
}).catch(e => {
|
||||
off(e && e.message === 'none'
|
||||
? 'The first desktop release is not published yet. Until then the storebound CLI does everything the app shows.'
|
||||
: 'Could not read the release list (' + (e && e.message) + '). desktop/latest.json has every file and its SHA-256.');
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -2,10 +2,6 @@
|
|||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://storebound.dev/</loc>
|
||||
<lastmod>2026-10-12</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://storebound.dev/desktop/</loc>
|
||||
<lastmod>2026-10-12</lastmod>
|
||||
<lastmod>2026-10-13</lastmod>
|
||||
</url>
|
||||
</urlset>
|
||||
|
|
|
|||
|
|
@ -661,3 +661,88 @@ h2 { font-size: clamp(28px, 4.2vw, 40px); font-weight: 720; letter-spacing: -.03
|
|||
.st-next .ic { width: 15px; height: 15px; transition: transform .15s ease; }
|
||||
.st-next:hover .ic { transform: translateX(2px); }
|
||||
@media (min-width: 1024px) { html.js .st-panel:not([hidden]) { display: flex; flex-direction: column; } }
|
||||
|
||||
/* ---- 2026-10-13 one-page story: hero teaser, the whole job, desktop tabs + downloads, your AI, what it covers ---- */
|
||||
/* phone: the section links scroll sideways in a second row instead of disappearing */
|
||||
@media (max-width: 640px) {
|
||||
.top-in { height: auto; flex-wrap: wrap; padding-top: 10px; padding-bottom: 6px; row-gap: 4px; }
|
||||
.top nav.nav-main { position: relative; order: 3; width: calc(100% + 2 * var(--gutter)); overflow-x: auto; scrollbar-width: none; margin: 0 calc(-1 * var(--gutter)); padding: 0 var(--gutter) 4px; }
|
||||
.top nav.nav-main::-webkit-scrollbar { display: none; }
|
||||
.top nav.nav-main a:not(.nav-git) { display: inline-block; white-space: nowrap; flex: none; padding: 6px 9px; font-size: 14px; }
|
||||
.top nav.nav-main .nav-git { flex: none; }
|
||||
html { scroll-padding-top: 108px; }
|
||||
}
|
||||
.hero-video { margin: 0; min-width: 0; position: relative; }
|
||||
.hero-video::before { content: ""; position: absolute; inset: 8% -4% -6% 6%; z-index: -1; border-radius: 40px; background: color-mix(in srgb, var(--accent) 28%, transparent); filter: blur(60px); opacity: .5; }
|
||||
.hero-video video { width: 100%; aspect-ratio: 16 / 9; border-radius: 14px; border: 1px solid rgb(148 163 184 / .2); background: #0b1020; box-shadow: var(--shadow); }
|
||||
.hero-video figcaption { margin-top: 12px; font-size: 13px; color: var(--muted); text-align: center; text-wrap: balance; }
|
||||
|
||||
.sub-h { font-size: 20px; font-weight: 700; margin: 56px 0 18px; }
|
||||
.ship { display: grid; gap: 28px; margin-top: 36px; align-items: start; }
|
||||
@media (min-width: 1020px) { .ship { grid-template-columns: minmax(0, 1.2fr) minmax(0, .8fr); } }
|
||||
.does { list-style: none; padding: 0; margin: 0; display: grid; gap: 12px; }
|
||||
.does li { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; display: grid; gap: 4px; min-width: 0; }
|
||||
.does b { font-size: 16px; color: var(--ink); }
|
||||
.does span { font-size: 14.5px; color: var(--ink-2); }
|
||||
.ship-demo { max-width: 760px; }
|
||||
.cols-you { display: grid; gap: 20px; min-width: 0; }
|
||||
.cols-you .sub { margin-top: 0; }
|
||||
.you-box { border: 1px solid var(--you-line); background: color-mix(in srgb, var(--you-bg) 45%, var(--surface)); border-radius: var(--radius); padding: 18px 20px; }
|
||||
.you-box ul { margin: 12px 0 0; padding-left: 18px; color: var(--ink-2); font-size: 15px; display: grid; gap: 6px; }
|
||||
.you-box strong { color: var(--ink); }
|
||||
|
||||
/* real CLI output as text */
|
||||
.term-real { margin: 0; }
|
||||
.term-real pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12.5px; line-height: 1.6; max-height: 560px; overflow-y: auto; }
|
||||
.term-real .o { color: color-mix(in srgb, var(--code-ink) 62%, var(--code-bg)); }
|
||||
.term-cap { padding: 0 18px 14px; font-size: 12px; color: var(--code-muted); }
|
||||
.term-cap a { color: inherit; }
|
||||
@media (max-width: 640px) { .term-real pre { font-size: 11.5px; padding: 14px; } }
|
||||
|
||||
/* desktop app: one tab per screen, light / dark captures */
|
||||
.desk-tabs { margin-top: 32px; }
|
||||
.desk-panel { margin: 0; }
|
||||
.desk-panel img { width: 100%; border-radius: 12px; border: 1px solid var(--line); box-shadow: var(--shadow); background: var(--surface); }
|
||||
.desk-panel figcaption { margin-top: 12px; font-size: 14px; color: var(--muted); }
|
||||
|
||||
.dl { margin-top: 44px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 22px; min-width: 0; }
|
||||
.dl-head h3 { font-size: 20px; }
|
||||
.dl-status { margin-top: 6px; font-size: 14.5px; color: var(--muted); }
|
||||
.dl-os { list-style: none; padding: 0; margin: 18px 0 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 190px), 1fr)); }
|
||||
.dl-os .btn { width: 100%; flex-direction: column; align-items: flex-start; gap: 2px; padding: 12px 16px; min-height: 64px; }
|
||||
.dl-os .btn b { font-size: 15.5px; }
|
||||
.dl-os .btn small { font-weight: 500; font-size: 12.5px; color: var(--muted); }
|
||||
.dl-os .btn.is-off { opacity: .6; cursor: default; pointer-events: none; }
|
||||
.dl-os .btn.is-here { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent) inset; }
|
||||
.dl-notice { margin-top: 16px; padding: 14px 16px; border-radius: 12px; border: 1px solid var(--ui-ink); background: var(--ui-bg); color: var(--ui-ink); font-size: 14.5px; }
|
||||
.dl-notice ol { margin: 8px 0 0; padding-left: 20px; }
|
||||
.dl-verify { margin-top: 18px; }
|
||||
.dl-verify summary, .chat-more summary { cursor: pointer; font-weight: 600; color: var(--ink); min-height: 32px; }
|
||||
.dl-table { width: 100%; border-collapse: collapse; margin-top: 14px; font-size: 14.5px; }
|
||||
.dl-table th, .dl-table td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||||
.dl-table th { font: 600 12px var(--mono); text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
|
||||
.dl-table .sha { font: 12px/1.5 var(--mono); overflow-wrap: anywhere; display: block; }
|
||||
.dl-table .fname { font-weight: 600; overflow-wrap: anywhere; }
|
||||
.dl-table .small { font-size: 13px; color: var(--muted); }
|
||||
@media (max-width: 720px) {
|
||||
.dl-table thead { display: none; }
|
||||
.dl-table, .dl-table tbody, .dl-table tr, .dl-table td { display: block; width: 100%; }
|
||||
.dl-table td { border: 0; padding: 4px 0; }
|
||||
.dl-table tr { border-bottom: 1px solid var(--line); padding: 8px 0; }
|
||||
}
|
||||
.dl-steps { margin: 14px 0 0; padding-left: 20px; color: var(--ink-2); font-size: 14.5px; }
|
||||
.dl-steps li { margin: 6px 0; }
|
||||
.dl-key { display: block; margin-top: 6px; font: 12.5px/1.6 var(--mono); overflow-wrap: anywhere; }
|
||||
|
||||
.agent-setup { min-width: 0; }
|
||||
.session .sub-h { margin-top: 0; }
|
||||
.chat-more { margin-top: 32px; }
|
||||
.chat-more .chat { margin-top: 14px; }
|
||||
|
||||
.covers { list-style: none; padding: 0; margin: 36px 0 0; display: grid; gap: 12px; }
|
||||
@media (min-width: 640px) { .covers { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (min-width: 980px) { .covers { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||||
.covers a { display: grid; align-content: start; gap: 4px; height: 100%; padding: 16px 18px; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; text-decoration: none; color: var(--ink-2); font-size: 14.5px; transition: border-color .15s ease; }
|
||||
.covers a:hover { border-color: var(--accent); }
|
||||
.covers b { color: var(--ink); font-size: 16px; }
|
||||
.ship-demo .demo::before { inset: 8% 0 -6% 6%; }
|
||||
|
|
|
|||