Files
SoulStream/handoff/rounds/patch-round38.cjs
T
KeelrLeek 9f0601027c android: the handoff — the pending rounds, the pipeline, and what is unverified
Everything written this session that has not been through the device: rounds 43
to 48 (each patch adversarially verified, 43's transient-notice expectation
fixed), a partial round 49, the pipeline scripts, the Android Auto design and
the icon comparison mock. Also the two harness changes that were never A/B'd —
adaptive waits, which may only shorten a declared wait and never lengthen it,
and a ship that runs the mock walk and the deploy at the same time. The A/B and
its 255-step baseline are in handoff/tools; main stays at what the phone has
actually walked.
2026-09-14 07:57:41 -05:00

212 lines
13 KiB
JavaScript

// Round thirty-eight: the whole discography. ANDROID.md listed the desktop's
// dedicated discography page as still desktop-only, and the phone's artist
// page already had the machinery for it — disco.js over the shared
// localReleaseType.js and editions.js. What was missing was the page: All in
// front of the categories, a sort, a grouping by year, and the editions kept
// whole rather than flattened away. The desktop's own page is a stack of
// album-page heroes at --cover-scale 0.45 — a 108px cover each, with the
// record's whole tracklist under it, fetched as the section scrolls near and
// its accent sampled off its own cover; on a 411px screen that is one record
// per screenful and one catalog_album per row over mobile data, so those
// three stay behind and are named in the doc as departures. The other half
// of the round is the cap: the artist page rendered the WHOLE active tab,
// with no slice anywhere in it, so a 140-release artist drew 140 cards — it
// shows twelve now, with the way down under them, and the page itself
// renders sixty at a time. No Rust and no Kotlin.
const fs = require('fs');
const root = process.env.SS_ROOT || 'F:/Coding/SoulStream/';
const scratch = 'C:/Users/Kyle/AppData/Local/Temp/claude/F--Coding-SoulStream/5488514a-11e8-482a-b0a0-20cb511ee110/scratchpad/';
const ins = (name) => fs.readFileSync(scratch + name, 'utf8').replace(/\s+$/, '');
let p, s;
/* Nothing reaches the disk until every anchor in the round has been found.
This round deletes a clause from an ANDROID.md bullet that round 37
rewraps — both shapes are written out below — and a neighbour rewording
anything else would make an anchor miss AFTER the screen had been
created, at which point the guard below refuses the re-run and the tree
is stuck half-patched. `save` holds the text; `flush` writes it, once,
at the end. */
const pending = new Map();
const load = (file) => { p = file; s = pending.has(file) ? pending.get(file) : fs.readFileSync(root + p, 'utf8'); };
const save = () => { pending.set(p, s); };
const flush = () => { for (const [f, t] of pending) fs.writeFileSync(root + f, t); };
const once = (a) => {
const i = s.indexOf(a);
if (i < 0) throw new Error('missing in ' + p + ': ' + a.slice(0, 90));
if (s.indexOf(a, i + 1) >= 0) throw new Error('ambiguous in ' + p + ': ' + a.slice(0, 90));
return i;
};
const rep = (a, b) => { once(a); s = s.replace(a, () => b); };
/* One anchor in this round has two known shapes: the "Still desktop-only,
smaller" bullet is round 37's as well, and 37 takes the drag-reorder
clause out of it and REWRAPS what is left, which puts "the full
discography page" across two lines. Exactly one of the shapes has to be
in the file — neither, or both, is a bullet nobody here recognises and
the round refuses rather than guessing. */
const repOneOf = (...pairs) => {
const hit = pairs.filter(([a]) => s.includes(a));
if (hit.length !== 1) {
throw new Error('the "Still desktop-only, smaller" bullet in ' + p + ' matches '
+ hit.length + ' of the ' + pairs.length + ' shapes round 38 knows — re-read it and add this one');
}
rep(hit[0][0], hit[0][1]);
};
const NL = String.fromCharCode(10);
/* Both the disco.js block and the shell.css block are APPENDS with no anchor
at all, so a second run would write them twice and `once` would only
notice further down, if at all. Refuse one here, before anything is even
read: the screen is the file this round creates and nothing else in the
repo makes it. */
if (fs.existsSync(root + 'src/mobile/screens/Discography.svelte')) {
throw new Error('src/mobile/screens/Discography.svelte already exists — round 38 has already run against ' + root);
}
// ── the classifier, seen whole: All, the sort, the year groups ─────────
// Appended; the guard above is what refuses a second run.
load('src/mobile/disco.js');
s = s.replace(/\s*$/, NL) + NL + ins('ins-r38-disco.txt') + NL;
save();
// ── the page ──────────────────────────────────────────────────────────
pending.set('src/mobile/screens/Discography.svelte', ins('ins-r38-screen.txt') + NL);
// ── the artist page: the way down, and the cap that makes it earn its
// place. The grid had no slice at all before this.
load('src/mobile/screens/Artist.svelte');
rep(` <div class="sec"><h2>Discography <em>· {releasedCount} on the catalog</em></h2></div>`,
` <div class="sec">
<h2>Discography <em>· {releasedCount} on the catalog</em></h2>
<button class="all" onclick={() => push("disco", { name }, screenEl)} aria-label="All releases">All<Icon name="chevron-right" size={14} /></button>
</div>`);
rep(` const families = $derived(tabs.find((t) => t.key === tab)?.items ?? []);`,
` /* the grid is a SLICE — the whole tab lives on the discography page,
which the head's "All" and the link under the grid both open. Before
this an artist with 140 releases rendered 140 cards here. */
const GRID_CAP = 12;
const activeTab = $derived(tabs.find((t) => t.key === tab) ?? null);
const families = $derived((activeTab?.items ?? []).slice(0, GRID_CAP));
const tabCount = $derived(activeTab?.items.length ?? 0);`);
rep(` onmore={() => openHalf("catalogAlbum", { album: { ...(f.editions?.[0] ?? f), artist: name } })}
/>
{/each}
</div>
{/if}`,
` onmore={() => openHalf("catalogAlbum", { album: { ...(f.editions?.[0] ?? f), artist: name } })}
/>
{/each}
</div>
{#if tabCount > GRID_CAP}
<button class="more-link dg-all" onclick={() => push("disco", { name, tab }, screenEl)}>Show all {tabCount} {activeTab?.label ?? "releases"}</button>
{/if}
{/if}`);
save();
// ── the shell: the screen, the glass bar's words, the ground ──────────
load('src/mobile/Mobile.svelte');
rep(` import Artist from "./screens/Artist.svelte";`,
` import Artist from "./screens/Artist.svelte";
import Discography from "./screens/Discography.svelte";`);
rep(` {:else if entry.n === "artist"}
<Artist name={entry.c.name} seed={entry.c.seed ?? null} {screenEl} />`,
` {:else if entry.n === "artist"}
<Artist name={entry.c.name} seed={entry.c.seed ?? null} {screenEl} />
{:else if entry.n === "disco"}
<Discography {entry} {screenEl} />`);
rep(` case "artist": {`, ins('ins-r38-mobile-meta.txt') + NL + ` case "artist": {`);
save();
// ── the sort sheet. Both edits are the FIRST clause of their chain, the
// furthest from anything a neighbour round would append.
load('src/mobile/HalfSheet.svelte');
rep(` if (t.n === "album") return "album-more";`,
` if (t.n === "disco") return "discosort";
if (t.n === "album") return "album-more";`);
rep(` {:else if kind === "librarysort"}`,
ins('ins-r38-halfsheet.txt') + NL + ` {:else if kind === "librarysort"}`);
save();
// ── the look. Appended after round thirty-four's block, the precedent.
load('src/mobile/shell.css');
s = s.replace(/\s*$/, NL) + NL + ins('ins-r38-css.txt') + NL;
save();
// ── the mock: an artist prolific enough that the cap is walked ────────
load('src/lib/mockLibrary.js');
rep(`/* the heroes chosen this session — pick_image → set_artist_hero */`,
ins('ins-r38-mock-big.txt') + NL + `/* the heroes chosen this session — pick_image → set_artist_hero */`);
/* the `const a = …` chain appears three times; only with the line under it
is either one unique */
rep(` const a = id === CAT_ARTIST.id ? CAT_ARTIST : id === CAT_SLOW.id ? CAT_SLOW : RELATED.find((r) => r.id === id);
if (!a) return Promise.reject(new Error("catalog_artist: unknown artist"));`,
` const a = id === CAT_ARTIST.id ? CAT_ARTIST : id === CAT_SLOW.id ? CAT_SLOW : id === CAT_BIG.id ? CAT_BIG : RELATED.find((r) => r.id === id);
if (!a) return Promise.reject(new Error("catalog_artist: unknown artist"));`);
rep(` albums: own ? CAT_DISCO : slow ? SLOW_ALBUMS : [{ id: 900 + id`,
` albums: own ? CAT_DISCO : slow ? SLOW_ALBUMS : a === CAT_BIG ? BIG_ALBUMS : [{ id: 900 + id`);
rep(` const a = id === CAT_ARTIST.id ? CAT_ARTIST : id === CAT_SLOW.id ? CAT_SLOW : RELATED.find((r) => r.id === id);
if (!a) return Promise.reject(new Error("catalog_artist_overview: unknown artist"));`,
` const a = id === CAT_ARTIST.id ? CAT_ARTIST : id === CAT_SLOW.id ? CAT_SLOW : id === CAT_BIG.id ? CAT_BIG : RELATED.find((r) => r.id === id);
if (!a) return Promise.reject(new Error("catalog_artist_overview: unknown artist"));`);
rep(` const albums = id === CAT_SLOW.id ? SLOW_ALBUMS.slice(0, 4) : id === CAT_ARTIST.id ? CAT_DISCO.slice(0, 4) : [];`,
` const albums = id === CAT_SLOW.id ? SLOW_ALBUMS.slice(0, 4) : id === CAT_ARTIST.id ? CAT_DISCO.slice(0, 4) : id === CAT_BIG.id ? BIG_ALBUMS.slice(0, 4) : [];`);
save();
// ── the walks ─────────────────────────────────────────────────────────
load('tests/mobile/smoke.mjs');
rep(` disco: document.querySelectorAll('.grid2 .release').length,`,
` disco: document.querySelectorAll('.grid2 .release').length,` + NL + ins('ins-r38-probe.txt'));
/* the mock steps stay in the artist block, where the page they test lives */
rep(` { do: \`__click('.disco-tabs button', 'Singles')\`, name: "cat-artist-tab", wait: 700 },`,
` { do: \`__click('.disco-tabs button', 'Singles')\`, name: "cat-artist-tab", wait: 700 },` + NL + ins('ins-r38-mock-steps.txt'));
rep(` "cat-artist-tab": (i) => i.title.startsWith("Daft Punk") && i.cardImgs === 2 && i.cardPhs === 2,`,
` "cat-artist-tab": (i) => i.title.startsWith("Daft Punk") && i.cardImgs === 2 && i.cardPhs === 2,` + NL + ins('ins-r38-mock-expect.txt'));
/* the phone walk's own two anchors are round thirty-four's — the last step
and the last expectation — so rounds appending here stack in order */
rep(` { do: \`document.querySelectorAll('.nav button')[0].click()\`, name: "end", wait: 800 },`,
ins('ins-r38-phone-steps.txt') + NL + ` { do: \`document.querySelectorAll('.nav button')[0].click()\`, name: "end", wait: 800 },`);
rep(` end: (i) => /^(Good |Still)/.test(i.title),`,
ins('ins-r38-phone-expect.txt') + NL + ` end: (i) => /^(Good |Still)/.test(i.title),`);
save();
// ── the doc ───────────────────────────────────────────────────────────
load('ANDROID.md');
rep(`CatalogAlbum (a catalog record, rows the library has play), Playlist,`,
`CatalogAlbum (a catalog record, rows the library has play), Discography (every release, on one page), Playlist,`);
rep(`The **catalog record page** is the desktop's album page: the kind, the`,
ins('ins-r38-doc.txt') + NL + NL + `The **catalog record page** is the desktop's album page: the kind, the`);
/* the walk's own order: my steps sit in the catalog-artist block, where the
page they test is reached from — not beside the Library artist page
earlier in the same sentence */
rep(`catalog search → a song through the resolver →`,
`catalog search → that artist's whole discography on a page of its own (All
in front of six categories, a year header per year, the editions opened in
place, a name order dropping the grouping, and a 140-record artist whose
grid caps at twelve and whose page shows sixty with the rest a button away)
→ a song through the resolver →`);
/* and the line this round closes, out of "Not done yet". The bullet is
rewrapped rather than left with a half-empty line in the middle of it —
which is why both shapes are spelled out whole: round 37 takes the drag
reorder out of the same bullet and rewraps it first, and after that "the
full discography page" is split across two lines. Whichever of the two
is in the file, what comes out is a bullet that reads. */
repOneOf(
[`- **Still desktop-only, smaller.** Drag reorder (Move up / down / to the top
stand in), catalog track previews, the full discography page, and the
fused-artist portrait verdict — that last one needs the identity merge the
phone does not run, so porting it means porting the merge first.`,
`- **Still desktop-only, smaller.** Drag reorder (Move up / down / to the top
stand in), catalog track previews and the fused-artist portrait verdict —
that last one needs the identity merge the phone does not run, so porting
it means porting the merge first.`],
[`- **Still desktop-only, smaller.** Catalog track previews, the full
discography page, and the fused-artist portrait verdict — that last one
needs the identity merge the phone does not run, so porting it means
porting the merge first.`,
`- **Still desktop-only, smaller.** Catalog track previews and the
fused-artist portrait verdict — that last one needs the identity merge the
phone does not run, so porting it means porting the merge first.`],
);
save();
flush();
console.log('round 38 wired');