feat: Show me more appends (exclude already-shown, look up only new domains); 36 per page; start over (v0.6.0)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XStQKxPfEjrTvWo83KFCxG
This commit is contained in:
2026-08-17 00:10:26 -05:00
parent cce9ee3c7c
commit ff0a262c43
5 changed files with 68 additions and 17 deletions

View File

@@ -40,6 +40,12 @@ bucket, and a 10-minute result cache.
`(keywords, style, seed)` always yields the same list, so re-renders don't `(keywords, style, seed)` always yields the same list, so re-renders don't
reshuffle results and a shared URL reproduces what the sender saw. reshuffle results and a shared URL reproduces what the sender saw.
The finder shows 36 names per page and **Show me more appends**: the names
already on screen are passed as `exclude`, dropped before ranking, so each page
is genuinely new (the seed alone would mostly reshuffle the same pool), and only
the new domains get looked up. Changing keywords, style or extensions starts a
fresh list; **start over** clears it. When the pool runs dry the app says so.
Eight strategies (compound, suffix-word, action, blend, coined, clipped, root, Eight strategies (compound, suffix-word, action, blend, coined, clipped, root,
domain hack) over the vocabulary in `src/lib/wordbank.ts`. The strategies are domain hack) over the vocabulary in `src/lib/wordbank.ts`. The strategies are
cheap and dumb on purpose — **quality comes from `scoreName`**, so tune the cheap and dumb on purpose — **quality comes from `scoreName`**, so tune the

View File

@@ -1,6 +1,6 @@
{ {
"name": "names-poweredbyotm", "name": "names-poweredbyotm",
"version": "0.5.1", "version": "0.6.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --port 3000", "dev": "next dev --port 3000",

View File

@@ -21,7 +21,7 @@ import { AiControls } from "./AiControls";
/** How many names to put on screen. Each one costs `selectedTlds.length` /** How many names to put on screen. Each one costs `selectedTlds.length`
* registry lookups, so this is the main dial on how hard we lean on the * registry lookups, so this is the main dial on how hard we lean on the
* registries — keep it modest. */ * registries — keep it modest. */
const NAME_COUNT = 24; const NAME_COUNT = 36;
const STYLES: Array<{ id: Style; label: string; note: string }> = [ const STYLES: Array<{ id: Style; label: string; note: string }> = [
{ id: "balanced", label: "Balanced", note: "a bit of everything" }, { id: "balanced", label: "Balanced", note: "a bit of everything" },
@@ -52,6 +52,9 @@ export default function Finder() {
// as a one-line summary so it's obvious whether AI actually contributed // as a one-line summary so it's obvious whether AI actually contributed
// (issue #3). null until a run completes. // (issue #3). null until a run completes.
const [aiCount, setAiCount] = useState<number | null>(null); const [aiCount, setAiCount] = useState<number | null>(null);
// What the current list was built from. "Show me more" APPENDS while this
// matches; changing keywords/style/extensions starts a fresh list.
const [listKey, setListKey] = useState<string | null>(null);
// Hide names whose every checked extension is taken (issue #3). Persisted. // Hide names whose every checked extension is taken (issue #3). Persisted.
const [hideTaken, setHideTaken] = useState(false); const [hideTaken, setHideTaken] = useState(false);
const { saved, savedNames, toggleSaved, removeSaved, recheckSaved, copySaved, rechecking, copied } = const { saved, savedNames, toggleSaved, removeSaved, recheckSaved, copySaved, rechecking, copied } =
@@ -101,7 +104,7 @@ export default function Finder() {
); );
const lookup = useCallback(async (domains: string[], myRun: number) => { const lookup = useCallback(async (domains: string[], myRun: number) => {
setPending(new Set(domains)); setPending((prev) => new Set([...prev, ...domains]));
for (let i = 0; i < domains.length; i += BATCH) { for (let i = 0; i < domains.length; i += BATCH) {
if (runId.current !== myRun) return; // superseded by a newer search if (runId.current !== myRun) return; // superseded by a newer search
@@ -129,17 +132,25 @@ export default function Finder() {
}, []); }, []);
const run = useCallback( const run = useCallback(
async (nextSeed: number) => { async (nextSeed: number, append: boolean) => {
const myRun = ++runId.current; // A fresh list abandons in-flight lookups; an append shares the current
// run so the earlier cards keep resolving.
const myRun = append ? runId.current : ++runId.current;
const base = append ? ideas : [];
setBusy(true); setBusy(true);
setError(null); setError(null);
if (!append) {
setChecks({}); setChecks({});
setPending(new Set());
}
const shown = new Set(base.map((i) => i.name));
let list = generateNames({ let list = generateNames({
keywords: parsedKeywords, keywords: parsedKeywords,
style, style,
count: NAME_COUNT, count: NAME_COUNT,
seed: nextSeed, seed: nextSeed,
exclude: shown,
}); });
// AI names: the visitor's own key if they saved one and asked us to use // AI names: the visitor's own key if they saved one and asked us to use
@@ -154,11 +165,11 @@ export default function Finder() {
? await generateWithAi(ai, parsedKeywords, style, 16, brief) ? await generateWithAi(ai, parsedKeywords, style, 16, brief)
: await generateWithPlatformAi(parsedKeywords, style, 16, brief); : await generateWithPlatformAi(parsedKeywords, style, 16, brief);
if (runId.current !== myRun) return; if (runId.current !== myRun) return;
const seen = new Set(list.map((i) => i.name)); const seen = new Set([...shown, ...list.map((i) => i.name)]);
const extra: Idea[] = aiNames const extra: Idea[] = aiNames
.filter((n) => !seen.has(n)) .filter((n) => !seen.has(n))
.map((name) => ({ name, strategy: "ai" as const, score: scoreName(name, parsedKeywords) })); .map((name) => ({ name, strategy: "ai" as const, score: scoreName(name, parsedKeywords) }));
list = [...extra, ...list].slice(0, NAME_COUNT + extra.length); list = [...extra, ...list];
gotAi = extra.length; gotAi = extra.length;
} catch (err) { } catch (err) {
setError( setError(
@@ -168,18 +179,37 @@ export default function Finder() {
} }
if (runId.current !== myRun) return; if (runId.current !== myRun) return;
setIdeas(list); if (list.length === 0 && append) {
setAiCount(wantsOwnKey || wantsPlatform ? gotAi : null); setError("That's everything the generator has for these keywords — try different words or a different style.");
setBusy(false);
return;
}
setIdeas([...base, ...list]);
setAiCount((prev) => (wantsOwnKey || wantsPlatform ? (append ? (prev ?? 0) + gotAi : gotAi) : append ? prev : null));
setBusy(false); setBusy(false);
await lookup(domainsFor(list, tlds), myRun); await lookup(domainsFor(list, tlds), myRun);
}, },
[account, ai, brief, lookup, parsedKeywords, platformAi, style, tlds, useAi, usePlatformAi], [account, ai, brief, ideas, lookup, parsedKeywords, platformAi, style, tlds, useAi, usePlatformAi],
); );
const queryKey = `${parsedKeywords.join(",")}|${style}|${[...tlds].sort().join(",")}`;
const onGenerate = () => { const onGenerate = () => {
const next = seed + 1; const next = seed + 1;
setSeed(next); setSeed(next);
void run(next); const append = listKey === queryKey && ideas.length > 0;
setListKey(queryKey);
void run(next, append);
};
const onStartOver = () => {
runId.current++;
setIdeas([]);
setChecks({});
setPending(new Set());
setAiCount(null);
setListKey(null);
setError(null);
}; };
const toggleTld = (tld: string) => { const toggleTld = (tld: string) => {
@@ -256,7 +286,7 @@ export default function Finder() {
<div className="controls"> <div className="controls">
<button className="primary" onClick={onGenerate} disabled={busy || tlds.length === 0}> <button className="primary" onClick={onGenerate} disabled={busy || tlds.length === 0}>
{busy ? "Thinking…" : ideas.length ? "Show me more" : "Find names"} {busy ? "Thinking…" : ideas.length && listKey === queryKey ? "Show me more" : "Find names"}
</button> </button>
<label <label
@@ -355,6 +385,10 @@ export default function Finder() {
<>{ideas.length} names</> <>{ideas.length} names</>
)} )}
{hideTaken && hiddenCount > 0 ? <> · {hiddenCount} taken hidden</> : null} {hideTaken && hiddenCount > 0 ? <> · {hiddenCount} taken hidden</> : null}
{" · "}
<button type="button" className="linkish" onClick={onStartOver}>
start over
</button>
</span> </span>
<label className="results-toggle"> <label className="results-toggle">
<input type="checkbox" checked={hideTaken} onChange={(e) => toggleHideTaken(e.target.checked)} /> <input type="checkbox" checked={hideTaken} onChange={(e) => toggleHideTaken(e.target.checked)} />
@@ -378,7 +412,7 @@ export default function Finder() {
</div> </div>
<div className="results-more"> <div className="results-more">
<button className="primary" onClick={onGenerate} disabled={busy || tlds.length === 0}> <button className="primary" onClick={onGenerate} disabled={busy || tlds.length === 0}>
{busy ? "Thinking…" : "Show me more"} {busy ? "Thinking…" : listKey === queryKey ? "Show me more" : "Find names"}
</button> </button>
<button type="button" className="mini" onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}> <button type="button" className="mini" onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}>
Back to top Back to top

View File

@@ -59,6 +59,9 @@ export interface GenerateOptions {
count?: number; count?: number;
/** Changing this reshuffles the whole list. Same seed ⇒ same output. */ /** Changing this reshuffles the whole list. Same seed ⇒ same output. */
seed?: number; seed?: number;
/** Names already shown — dropped before ranking so "show me more" appends
* genuinely new ideas instead of a reshuffle of the same pool. */
exclude?: ReadonlySet<string>;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -309,7 +312,7 @@ const STYLE_WEIGHTS: Record<Style, Record<GeneratedStrategy, number>> = {
* and only the top `count` survive. * and only the top `count` survive.
*/ */
export function generateNames(opts: GenerateOptions): Idea[] { export function generateNames(opts: GenerateOptions): Idea[] {
const { keywords, style = "balanced", count = 60, seed = 1 } = opts; const { keywords, style = "balanced", count = 60, seed = 1, exclude } = opts;
const rng = makeRng(seed); const rng = makeRng(seed);
const { seeds, related } = expandKeywords(keywords); const { seeds, related } = expandKeywords(keywords);
@@ -408,7 +411,7 @@ export function generateNames(opts: GenerateOptions): Idea[] {
} }
const ranked = [...pool.values()] const ranked = [...pool.values()]
.filter((i) => i.score > 0) .filter((i) => i.score > 0 && !exclude?.has(i.name))
.sort((a, b) => b.score - a.score || a.name.length - b.name.length || a.name.localeCompare(b.name)); .sort((a, b) => b.score - a.score || a.name.length - b.name.length || a.name.localeCompare(b.name));
return diversify(ranked, count); return diversify(ranked, count);

View File

@@ -2,7 +2,7 @@
// /changelog. Bump VERSION and prepend an entry when shipping a user-facing // /changelog. Bump VERSION and prepend an entry when shipping a user-facing
// change. Same convention as rpo. // change. Same convention as rpo.
export const VERSION = "0.5.1"; export const VERSION = "0.6.0";
export interface ChangelogEntry { export interface ChangelogEntry {
version: string; version: string;
@@ -11,6 +11,14 @@ export interface ChangelogEntry {
} }
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.6.0",
date: "2026-08-17",
changes: [
"Show me more now adds to the list instead of replacing it, and only checks the new names — what you've already seen stays put and isn't looked up again. 36 names per page (was 24).",
"Each page is genuinely new: names already on screen are excluded before ranking, not just reshuffled. Changing keywords, style or extensions starts a fresh list; 'start over' clears it.",
],
},
{ {
version: "0.5.1", version: "0.5.1",
date: "2026-08-17", date: "2026-08-17",