mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 23:26:39 +03:00
Sync generated provider output
This commit is contained in:
@@ -31,6 +31,16 @@
|
||||
* recomputes what rounds 0..n-1 drew, excludes all of it, and rolls a
|
||||
* fresh assigned index, challengers, and compositions. One base key therefore
|
||||
* reproduces the entire chain of rounds.
|
||||
* - REGISTER (--register safer|bolder): the user's steering on the
|
||||
* familiar-to-bold axis, applied to a re-roll round. A register changes
|
||||
* only what this round instructs, never what it dealt: the same key and
|
||||
* reroll count reproduce the same deal whatever the register, so the
|
||||
* exclusion chain never forks. bolder presents the dealt foreign forms
|
||||
* as the whole hand (first-dealt leads, dice-assigned by deal order);
|
||||
* safer spends the dealt hand unseen and presents the familiar register,
|
||||
* the model's conventional grounded candidates plus the canon against
|
||||
* named competitors, the one sanctioned lineup of the model's own list.
|
||||
* Registers are user-requested, never pre-selected by the model.
|
||||
* - RATINGS: the reviewer's approval ratings weight the challenger draw
|
||||
* (3-star doubles the odds, 1-star sits out); the approved pool itself
|
||||
* is unchanged.
|
||||
@@ -41,7 +51,9 @@
|
||||
* node scripts/concept-seed.mjs --scope surface --mode operate --grain flow
|
||||
* node scripts/concept-seed.mjs --scope direction --candidate-count 6
|
||||
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1
|
||||
* node scripts/concept-seed.mjs --chosen <challenger-id> --from <key> --scope direction
|
||||
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1 --register bolder
|
||||
* node scripts/concept-seed.mjs --chosen <challenger-id> --kind challenger --from <key> --scope direction
|
||||
* node scripts/concept-seed.mjs --kind assigned --from <key> --scope direction
|
||||
*
|
||||
* --grain names how much of the product is in play: product, flow, view, or
|
||||
* region. A docs site, an onboarding flow, a landing page and a data table are
|
||||
@@ -62,8 +74,13 @@
|
||||
* Challenger data resolves in order: a local catalog directory (the private
|
||||
* service repo, evals, and tests set IMPECCABLE_CATALOG_DIR), then the roll
|
||||
* API at impeccable.style, then a degraded assignment-only seed when both are
|
||||
* unavailable. --chosen sends the anonymous choice ping for API-dealt rolls;
|
||||
* DO_NOT_TRACK or IMPECCABLE_NO_TELEMETRY disables it.
|
||||
* unavailable. The anonymous choice ping fires once per resolved attended
|
||||
* round on API-dealt rolls: --kind names which card class won (assigned,
|
||||
* pick, challenger, canon) so share metrics have a denominator, --chosen
|
||||
* carries the catalog id when a dealt challenger won, and --register rides
|
||||
* along when the round came from a steered hand. Grounded candidates' names
|
||||
* never leave the machine. DO_NOT_TRACK or IMPECCABLE_NO_TELEMETRY disables
|
||||
* the ping entirely.
|
||||
*
|
||||
* Env vars:
|
||||
* IMPECCABLE_CONCEPT_SEED — same as --from; for reproducible eval runs.
|
||||
@@ -172,17 +189,35 @@ function telemetryDisabled() {
|
||||
return Boolean(process.env.IMPECCABLE_NO_TELEMETRY || process.env.DO_NOT_TRACK);
|
||||
}
|
||||
|
||||
// Anonymous choice ping: records only that a dealt world was selected.
|
||||
// Anonymous choice ping: one per resolved attended direction round. kind
|
||||
// says which card class won (assigned / pick / challenger / canon), so
|
||||
// pick-share and canon-share have a denominator; chosenId rides along only
|
||||
// when a dealt catalog world won, and register only when the round came from
|
||||
// a steered hand. Grounded candidates' names never leave the machine: they
|
||||
// are derived from the user's project, so the ping carries the kind alone.
|
||||
// Fire-and-forget; never fails the caller.
|
||||
export async function pingChosen({ chosenId, key, scope, mode }) {
|
||||
if (telemetryDisabled() || !chosenId) return false;
|
||||
const PING_KINDS = new Set(['assigned', 'pick', 'challenger', 'canon']);
|
||||
export async function pingChosen({ chosenId, key, scope, mode, kind, register }) {
|
||||
if (telemetryDisabled()) return false;
|
||||
if (kind && !PING_KINDS.has(kind)) return false;
|
||||
if (register && register !== 'safer' && register !== 'bolder') return false;
|
||||
// Legacy shape: a bare challenger id with no kind stays a valid ping.
|
||||
if (!chosenId && !kind) return false;
|
||||
if ((kind === 'challenger' || !kind) && !chosenId) return false;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
|
||||
try {
|
||||
await fetch(`${API_BASE}/chosen`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chosenId, key, scope, mode }),
|
||||
body: JSON.stringify({
|
||||
...(chosenId ? { chosenId } : {}),
|
||||
key,
|
||||
scope,
|
||||
mode,
|
||||
...(kind ? { kind } : {}),
|
||||
...(register ? { register } : {}),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
return true;
|
||||
@@ -260,6 +295,7 @@ export function renderConceptSeed({
|
||||
scope = 'surface',
|
||||
key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'),
|
||||
reroll = 0,
|
||||
register = null,
|
||||
mode = null,
|
||||
grain = null,
|
||||
platform = null,
|
||||
@@ -273,6 +309,15 @@ export function renderConceptSeed({
|
||||
if (!Number.isInteger(reroll) || reroll < 0) {
|
||||
throw new Error('concept-seed: --reroll must be a non-negative integer');
|
||||
}
|
||||
if (register !== null && register !== 'safer' && register !== 'bolder') {
|
||||
throw new Error('concept-seed: --register must be safer or bolder');
|
||||
}
|
||||
if (register !== null && reroll < 1) {
|
||||
throw new Error('concept-seed: --register steers a re-roll round; pass --reroll <n> with it');
|
||||
}
|
||||
if (register !== null && scope !== 'direction') {
|
||||
throw new Error('concept-seed: --register applies to direction rounds only');
|
||||
}
|
||||
if (mode !== null && !SEED_MODES.has(mode)) {
|
||||
throw new Error('concept-seed: --mode must be persuade, operate, read, or experience');
|
||||
}
|
||||
@@ -326,6 +371,7 @@ export function renderConceptSeed({
|
||||
scope,
|
||||
key,
|
||||
reroll,
|
||||
register,
|
||||
mode,
|
||||
grain,
|
||||
platform,
|
||||
@@ -357,7 +403,11 @@ export function renderConceptSeed({
|
||||
survive the current task plus navigation, quiet and dense content,
|
||||
interaction and state, and a substantially different future surface. In an
|
||||
attended run, present the assigned direction fully committed and offer
|
||||
re-roll; never present a ranked lineup to choose from. Re-roll yourself only
|
||||
re-roll. You may add ONE card for your top-ranked grounded candidate when
|
||||
it is not the assigned direction, kicker MY PICK, with an honest risk line
|
||||
naming its familiarity; one pick card, never a ranked lineup, and the pick
|
||||
never takes the lead position. When the assignment IS your top candidate,
|
||||
there is no pick card. Re-roll yourself only
|
||||
on named factual grounds, when the assignment cannot carry the product's
|
||||
truth or task; taste is never grounds.`
|
||||
: `After ordering the task's grounded structural candidates by resonance,
|
||||
@@ -374,7 +424,16 @@ export function renderConceptSeed({
|
||||
conflicts. Weigh the fused result against the assigned direction on exactly
|
||||
two axes, audience identification and product clarity. Losing to strong
|
||||
grounded material is a valid outcome; beating a thin or tool-monoculture
|
||||
list is the point. A fused challenger that wins both axes becomes the build.`
|
||||
list is the point. A fused challenger that wins both axes becomes the build.
|
||||
Close the weighing with a verdict per challenger, decided before any
|
||||
borrowing is considered: wins (beats the assigned direction on both axes),
|
||||
competitive (holds one axis), or declined (loses both). A declined
|
||||
challenger is not spent: name the one discipline of its system the assigned
|
||||
direction lacks, and raise the assigned direction to match before
|
||||
presenting it. A donation transfers ambition and system discipline, never
|
||||
the challenger's clothes; one world owns the page. Write each raise as its
|
||||
own named line on the presented direction, and carry every verdict, kept
|
||||
line, and raise into the decision page payload.`
|
||||
: `A challenger wins only when its fused result beats the grounded list on
|
||||
audience identification and product clarity. It may change task topology or
|
||||
interaction, but never the committed visual identity.`;
|
||||
@@ -399,8 +458,39 @@ Ambitious motion, spatial media, or interaction is welcome when it strengthens
|
||||
the product without weakening semantics, performance, or fallback behavior.`;
|
||||
|
||||
if (!data) {
|
||||
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: degraded; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''} --candidate-count ${candidateCount})
|
||||
ASSIGNED INDEX: ${buildIndex}
|
||||
// A degraded roll can still serve the safer register, which needs no
|
||||
// catalog at all: the assignment machinery is suppressed entirely, the
|
||||
// same as the non-degraded safer round, because emitting both "the user
|
||||
// picks" and a mandatory numbered build order hands the model two
|
||||
// contradicting instructions and the mandatory one tends to win. The
|
||||
// bolder register is exactly the thing degradation took away, so it
|
||||
// falls back to a plain grounded round, disclosed.
|
||||
const degradedHeader = `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: degraded; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''}${register ? ` --register ${register}` : ''} --candidate-count ${candidateCount})`;
|
||||
if (register === 'safer') {
|
||||
return `${degradedHeader}
|
||||
SAFER REGISTER (user-requested): the assigned index is suspended this
|
||||
round; the user picks, and no candidate is mandated. Present the familiar
|
||||
register: your remaining grounded candidates from the conventional end, at
|
||||
most three, as full cards with an honest risk line each, plus the canon
|
||||
executed against two or three named competitors. This is the one sanctioned
|
||||
lineup of your own ranked candidates; it exists only by this explicit
|
||||
request. When the user voices a standing preference for it, record a brand
|
||||
commitment in PRODUCT.md.
|
||||
${authorityInstruction}
|
||||
A user- or brief-pinned decision beats the roll, always.
|
||||
REGISTER (restated for truncated readers): safer, user-requested; the
|
||||
assigned index is suspended this round and the user picks; seed key ${key}.
|
||||
`;
|
||||
}
|
||||
const degradedRegister = register === 'bolder'
|
||||
? `BOLDER REGISTER UNAVAILABLE: bolder deals foreign forms, and this roll ran
|
||||
degraded with no catalog and no roll service, so there is nothing bold to
|
||||
deal. Tell the user, then run this round as a plain grounded re-roll; the
|
||||
assignment below applies.
|
||||
`
|
||||
: '';
|
||||
return `${degradedHeader}
|
||||
${degradedRegister}ASSIGNED INDEX: ${buildIndex}
|
||||
${promotedInstruction}
|
||||
The assignment exists to refuse the model's ranking rut, never to outrank
|
||||
the user or the brief. Never expose assignment metadata in user-facing labels.
|
||||
@@ -471,34 +561,76 @@ structure only, never a palette, typeface, or material. Treat them as serious
|
||||
rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n`
|
||||
: '';
|
||||
const rerollBlock = reroll > 0
|
||||
? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded
|
||||
and challenger alike, is eliminated and may not return reworded. Derive
|
||||
? `RE-ROLL ROUND ${reroll}${register ? ` (${register.toUpperCase()} REGISTER, user-requested)` : ''}: every candidate presented in earlier rounds, grounded
|
||||
and challenger alike, is eliminated and may not return reworded.${register ? '' : ` Derive
|
||||
genuinely new grounded candidates from unexplored angles before judging
|
||||
these fresh challengers.\n`
|
||||
these fresh challengers.`}\n`
|
||||
: '';
|
||||
// A register swaps the round's presentation, never its deal: the assigned
|
||||
// index and challenger fetch stay identical so the chain reproduces, and
|
||||
// only the instructions change.
|
||||
const saferBlock = `SAFER REGISTER: the user asked for the familiar end of the spectrum, so this
|
||||
round's dealt hand is spent unseen, stays excluded from future rounds, and
|
||||
is not printed. The assigned index is suspended this round; the user picks. Present the familiar register: your remaining grounded
|
||||
candidates from the conventional end, at most three, as full cards with an
|
||||
honest risk line each, plus the canon executed against two or three named
|
||||
competitors. This is the one sanctioned lineup of your own ranked
|
||||
candidates; it exists only by this explicit request. When the user voices a
|
||||
standing preference for it, record a brand commitment in PRODUCT.md.`;
|
||||
const bolderBlock = `BOLDER REGISTER: the user asked for foreign forms at full commitment, so no
|
||||
grounded direction is presented this round and the assigned index is
|
||||
suspended. The hand is every dealt challenger below, each fused with the
|
||||
product and presented as a full card; the FIRST dealt challenger leads, an
|
||||
assignment by deal order, so the dice still choose. Verdicts and donations
|
||||
apply between the challengers, weighed against the leader. The pick card
|
||||
sits out; the canon stays, as always.`;
|
||||
const telemetryBlock = data.source === 'api'
|
||||
? `TELEMETRY: if the resolved direction uses one of these challengers, rerun
|
||||
this script once with --chosen <challenger-id> --from ${key} --scope ${scope}${mode ? ` --mode ${mode}` : ''}
|
||||
after resolution. The ping is anonymous (chosen id only) and is skipped
|
||||
automatically when DO_NOT_TRACK or IMPECCABLE_NO_TELEMETRY is set.\n`
|
||||
? `TELEMETRY: after the user's choice resolves, rerun this script once with
|
||||
--kind <assigned|pick|challenger|canon> --from ${key} --scope ${scope}${mode ? ` --mode ${mode}` : ''},
|
||||
adding --chosen <challenger-id> when a dealt challenger won and keeping
|
||||
--register <safer|bolder> when the resolved round came from a steered hand.
|
||||
One ping per resolved attended round. The ping is anonymous, the card kind
|
||||
plus the catalog id when one won; your grounded candidates' names never
|
||||
leave the machine, and the ping is skipped automatically when DO_NOT_TRACK
|
||||
or IMPECCABLE_NO_TELEMETRY is set.\n`
|
||||
: '';
|
||||
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: ${data.source}; approved pool: ${data.poolRevision}; ${data.approvedCount}/${data.catalogCount} human-approved; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''} --candidate-count ${candidateCount} to reproduce this roll against this catalog revision)
|
||||
${rerollBlock}ASSIGNED INDEX: ${buildIndex}
|
||||
const assignedBlock = register === null
|
||||
? `ASSIGNED INDEX: ${buildIndex}
|
||||
${promotedInstruction}
|
||||
The assignment exists to refuse the model's ranking rut, never to outrank
|
||||
the user or the brief. Never expose assignment metadata in user-facing labels.
|
||||
CHALLENGERS:
|
||||
the user or the brief. Never expose assignment metadata in user-facing labels.`
|
||||
: register === 'safer' ? saferBlock : bolderBlock;
|
||||
// A bolder round has no assigned grounded direction, so the generic
|
||||
// weighing instruction (which measures against the assignment) would
|
||||
// contradict the register; the bolder variant weighs against the leader.
|
||||
const bolderChallengerInstruction = `Fuse each challenger before judging it: the challenger supplies the form
|
||||
and its system grammar, the product supplies every fact, and clarity wins
|
||||
conflicts. Weigh every fused challenger against the fused LEADER, the first
|
||||
dealt, on exactly two axes, audience identification and product clarity;
|
||||
verdicts and donations apply between the challengers, and one that beats
|
||||
the leader on both axes presents as the hand's strongest alternate.`;
|
||||
const roundChallengerInstruction = register === 'bolder' ? bolderChallengerInstruction : challengerInstruction;
|
||||
const challengerSection = register === 'safer'
|
||||
? ''
|
||||
: `CHALLENGERS:
|
||||
${data.challengers.map(renderChallenger).join('\n')}
|
||||
${compositionBlock}${challengerInstruction}
|
||||
${compositionBlock}${roundChallengerInstruction}
|
||||
When you can view images, open the QUALITY BAR board and hero for any
|
||||
challenger you weigh seriously and for the world you build. They exist as a
|
||||
craft bar, the finish level and commitment the build is expected to reach,
|
||||
never as a mockup to copy; your surface serves this product, not that render.
|
||||
${authorityInstruction}
|
||||
`;
|
||||
const restated = register === null
|
||||
? `ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
||||
${buildIndex} of your own grounded list; seed key ${key}.`
|
||||
: `REGISTER (restated for truncated readers): ${register}, user-requested; the
|
||||
assigned index is suspended this round; seed key ${key}.`;
|
||||
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: ${data.source}; approved pool: ${data.poolRevision}; ${data.approvedCount}/${data.catalogCount} human-approved; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''}${register ? ` --register ${register}` : ''} --candidate-count ${candidateCount} to reproduce this roll against this catalog revision)
|
||||
${rerollBlock}${assignedBlock}
|
||||
${challengerSection}${authorityInstruction}
|
||||
${richnessInstruction}
|
||||
${telemetryBlock}A user- or brief-pinned decision beats the roll, always.
|
||||
ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
|
||||
${buildIndex} of your own grounded list; seed key ${key}.
|
||||
${restated}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -507,19 +639,25 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
||||
const fromIdx = args.indexOf('--from');
|
||||
const scopeIdx = args.indexOf('--scope');
|
||||
const rerollIdx = args.indexOf('--reroll');
|
||||
const registerIdx = args.indexOf('--register');
|
||||
const modeIdx = args.indexOf('--mode');
|
||||
const grainIdx = args.indexOf('--grain');
|
||||
const platformIdx = args.indexOf('--platform');
|
||||
const candidateCountIdx = args.indexOf('--candidate-count');
|
||||
const chosenIdx = args.indexOf('--chosen');
|
||||
const kindIdx = args.indexOf('--kind');
|
||||
try {
|
||||
if (chosenIdx !== -1) {
|
||||
if (chosenIdx !== -1 || kindIdx !== -1) {
|
||||
// Choice ping: always exits 0, telemetry must never fail a design flow.
|
||||
// --kind alone pings a non-challenger outcome (assigned/pick/canon);
|
||||
// --chosen alone stays the legacy challenger-win ping.
|
||||
const sent = await pingChosen({
|
||||
chosenId: args[chosenIdx + 1],
|
||||
chosenId: chosenIdx !== -1 ? args[chosenIdx + 1] : undefined,
|
||||
key: fromIdx !== -1 ? args[fromIdx + 1] : undefined,
|
||||
scope: scopeIdx !== -1 ? args[scopeIdx + 1] : undefined,
|
||||
mode: modeIdx !== -1 ? args[modeIdx + 1] : undefined,
|
||||
kind: kindIdx !== -1 ? args[kindIdx + 1] : undefined,
|
||||
register: registerIdx !== -1 ? args[registerIdx + 1] : undefined,
|
||||
});
|
||||
process.stdout.write(sent ? 'choice recorded\n' : 'choice ping skipped\n');
|
||||
} else {
|
||||
@@ -542,6 +680,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
||||
? args[fromIdx + 1]
|
||||
: (process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex')),
|
||||
reroll: rerollIdx !== -1 ? Number(args[rerollIdx + 1]) : 0,
|
||||
register: registerIdx !== -1 ? args[registerIdx + 1] : null,
|
||||
mode: modeIdx !== -1 ? args[modeIdx + 1] : null,
|
||||
grain: grainIdx !== -1 ? args[grainIdx + 1] : null,
|
||||
platform: platformIdx !== -1 ? args[platformIdx + 1] : null,
|
||||
|
||||
@@ -206,10 +206,10 @@ function parseIgnoreColor(value) {
|
||||
if (rgb) {
|
||||
const parts = splitColorArgs(rgb[1]);
|
||||
if (parts.length < 3 || parts.length > 4) return null;
|
||||
const r = parseRgbChannel(parts[0]);
|
||||
const g = parseRgbChannel(parts[1]);
|
||||
const b = parseRgbChannel(parts[2]);
|
||||
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
||||
const r = parseColorChannel(parts[0], COLOR_CHANNEL_FORMATS.rgb);
|
||||
const g = parseColorChannel(parts[1], COLOR_CHANNEL_FORMATS.rgb);
|
||||
const b = parseColorChannel(parts[2], COLOR_CHANNEL_FORMATS.rgb);
|
||||
const a = parts[3] === undefined ? 1 : parseColorChannel(parts[3], COLOR_CHANNEL_FORMATS.alpha);
|
||||
if ([r, g, b, a].some((v) => v === null)) return null;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
@@ -218,10 +218,10 @@ function parseIgnoreColor(value) {
|
||||
if (hsl) {
|
||||
const parts = splitColorArgs(hsl[1]);
|
||||
if (parts.length < 3 || parts.length > 4) return null;
|
||||
const h = parseHueChannel(parts[0]);
|
||||
const s = parsePercentChannel(parts[1]);
|
||||
const l = parsePercentChannel(parts[2]);
|
||||
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
||||
const h = parseColorChannel(parts[0], COLOR_CHANNEL_FORMATS.hue);
|
||||
const s = parseColorChannel(parts[1], COLOR_CHANNEL_FORMATS.percent);
|
||||
const l = parseColorChannel(parts[2], COLOR_CHANNEL_FORMATS.percent);
|
||||
const a = parts[3] === undefined ? 1 : parseColorChannel(parts[3], COLOR_CHANNEL_FORMATS.alpha);
|
||||
if ([h, s, l, a].some((v) => v === null)) return null;
|
||||
return hslToRgb(h, s, l, a);
|
||||
}
|
||||
@@ -230,18 +230,13 @@ function parseIgnoreColor(value) {
|
||||
}
|
||||
|
||||
function parseHexIgnoreColor(hex) {
|
||||
if (hex.length === 3 || hex.length === 4) {
|
||||
const r = parseInt(hex[0] + hex[0], 16);
|
||||
const g = parseInt(hex[1] + hex[1], 16);
|
||||
const b = parseInt(hex[2] + hex[2], 16);
|
||||
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
|
||||
return { r, g, b, a };
|
||||
const expanded = hex.length <= 4
|
||||
? [...hex].map((digit) => digit.repeat(2)).join('')
|
||||
: hex;
|
||||
const [r, g, b, alpha = 255] = expanded
|
||||
.match(/../g)
|
||||
.map((channel) => Number.parseInt(channel, 16));
|
||||
return { r, g, b, a: alpha / 255 };
|
||||
}
|
||||
|
||||
function splitColorArgs(body) {
|
||||
@@ -259,47 +254,34 @@ function splitColorArgs(body) {
|
||||
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
|
||||
}
|
||||
|
||||
function parseRgbChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const scaled = match[2] ? value * 2.55 : value;
|
||||
if (scaled < 0 || scaled > 255) return null;
|
||||
return Math.round(scaled);
|
||||
}
|
||||
const CSS_NUMBER_RE = /^(-?\d*\.?\d+)(%|deg|rad|turn|grad)?$/;
|
||||
const identity = (value) => value;
|
||||
const COLOR_CHANNEL_FORMATS = {
|
||||
rgb: { units: { '': identity, '%': (value) => value * 2.55 }, min: 0, max: 255, round: true },
|
||||
alpha: { units: { '': identity, '%': (value) => value / 100 }, min: 0, max: 1 },
|
||||
hue: {
|
||||
units: {
|
||||
'': identity,
|
||||
deg: identity,
|
||||
rad: (value) => value * (180 / Math.PI),
|
||||
turn: (value) => value * 360,
|
||||
grad: (value) => value * 0.9,
|
||||
},
|
||||
},
|
||||
percent: { units: { '%': (value) => value / 100 }, min: 0, max: 1 },
|
||||
};
|
||||
|
||||
function parseAlphaChannel(raw) {
|
||||
function parseColorChannel(raw, { units, min = -Infinity, max = Infinity, round = false }) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
||||
const match = text.match(CSS_NUMBER_RE);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const alpha = match[2] ? value / 100 : value;
|
||||
return alpha >= 0 && alpha <= 1 ? alpha : null;
|
||||
}
|
||||
|
||||
function parseHueChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const unit = match[2] || 'deg';
|
||||
if (unit === 'turn') return value * 360;
|
||||
if (unit === 'rad') return value * (180 / Math.PI);
|
||||
if (unit === 'grad') return value * 0.9;
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePercentChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)%$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
return value >= 0 && value <= 100 ? value / 100 : null;
|
||||
const convert = units[match[2] || ''];
|
||||
if (!convert) return null;
|
||||
const number = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(number)) return null;
|
||||
const value = convert(number);
|
||||
if (value < min || value > max) return null;
|
||||
return round ? Math.round(value) : value;
|
||||
}
|
||||
|
||||
function hslToRgb(hue, saturation, lightness, alpha) {
|
||||
|
||||
@@ -97,23 +97,20 @@
|
||||
return { value: c.value, label: c.label };
|
||||
});
|
||||
|
||||
const LIVE_CHROME_MOUNT_CONTRACT = ['root', 'transport', 'state', 'actions'];
|
||||
const LIVE_UI_SURFACES = [
|
||||
{ key: 'global-bottom-bar', ids: [PREFIX + '-global-bar', PREFIX + '-global-bar-brand', PREFIX + '-pick-toggle', PREFIX + '-insert-toggle', PREFIX + '-detect-toggle', PREFIX + '-detect-badge', PREFIX + '-design-toggle', PREFIX + '-page-chat', PREFIX + '-page-chat-input', PREFIX + '-page-chat-voice', PREFIX + '-page-chat-send'] },
|
||||
{ key: 'pending-copy-edit-dock', ids: [PREFIX + '-pending-dock'] },
|
||||
{ key: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-selection-pill', PREFIX + '-input', PREFIX + '-configure-voice', PREFIX + '-configure-bar-tooltip'] },
|
||||
{ key: 'action-picker', ids: [PREFIX + '-picker'] },
|
||||
{ key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] },
|
||||
{ key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] },
|
||||
{ key: 'variant-cycling-row', ids: [PREFIX + '-bar', PREFIX + '-params-panel'] },
|
||||
{ key: 'variant-params-panel', ids: [PREFIX + '-params-panel'] },
|
||||
{ key: 'saving-confirmed-rows', ids: [PREFIX + '-bar'] },
|
||||
{ key: 'insert-mode-chrome', ids: [PREFIX + '-insert-line', PREFIX + '-insert-placeholder', PREFIX + '-placeholder-resize', PREFIX + '-insert-input', PREFIX + '-insert-voice', PREFIX + '-insert-create', PREFIX + '-insert-create-tooltip'] },
|
||||
{ key: 'annotation-chrome', ids: [PREFIX + '-annot', PREFIX + '-annot-svg', PREFIX + '-annot-pins', PREFIX + '-annot-clear'] },
|
||||
{ key: 'design-system-panel', ids: [PREFIX + '-design-host'] },
|
||||
{ key: 'toasts-and-errors', ids: [PREFIX + '-toast', PREFIX + '-mount-error'] },
|
||||
{ key: 'css-isolation-boundary', ids: [PREFIX + '-root'] },
|
||||
];
|
||||
// The Live chrome inventory (which surfaces exist, and the element ids each
|
||||
// one owns) comes from the canonical source, skill/scripts/live/ui-surfaces.mjs,
|
||||
// which the /live.js assembler serializes into these globals alongside the
|
||||
// token/port/vocabulary. This file is served raw and injected as a classic
|
||||
// script, so it cannot import that module; the private impeccable-site repo
|
||||
// imports it directly to check its Live UI lab holds a snapshot for every
|
||||
// surface, which only works while the list has exactly one definition.
|
||||
// Add a surface in ui-surfaces.mjs, not here.
|
||||
const LIVE_CHROME_MOUNT_CONTRACT = Array.isArray(window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__)
|
||||
? window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__
|
||||
: ['root', 'transport', 'state', 'actions'];
|
||||
const LIVE_UI_SURFACES = Array.isArray(window.__IMPECCABLE_LIVE_UI_SURFACES__)
|
||||
? window.__IMPECCABLE_LIVE_UI_SURFACES__
|
||||
: [];
|
||||
const LIVE_UI_COMPONENT_IDS = [...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids))];
|
||||
|
||||
//
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs';
|
||||
|
||||
export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([
|
||||
Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }),
|
||||
Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }),
|
||||
@@ -32,7 +34,20 @@ export function readLiveBrowserScriptParts(parts, readFile = (filePath) => fs.re
|
||||
}));
|
||||
}
|
||||
|
||||
export function assembleLiveBrowserScript({ token, port, vocabulary, commandPrefix = '/', appRoot = null, parts }) {
|
||||
export function assembleLiveBrowserScript({
|
||||
token,
|
||||
port,
|
||||
vocabulary,
|
||||
commandPrefix = '/',
|
||||
appRoot = null,
|
||||
parts,
|
||||
// Defaulted rather than threaded through live-server.mjs: the browser bundle
|
||||
// must always carry the canonical inventory, and a default makes that true by
|
||||
// construction instead of by every caller remembering to pass it. Overridable
|
||||
// so tests can assemble with a stand-in.
|
||||
uiSurfaces = LIVE_UI_SURFACES,
|
||||
mountContract = LIVE_CHROME_MOUNT_CONTRACT,
|
||||
}) {
|
||||
const prelude =
|
||||
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
|
||||
`window.__IMPECCABLE_PORT__ = ${port};\n` +
|
||||
@@ -44,7 +59,14 @@ export function assembleLiveBrowserScript({ token, port, vocabulary, commandPref
|
||||
`window.__IMPECCABLE_COMMAND_PREFIX__ = ${JSON.stringify(commandPrefix)};\n` +
|
||||
// Canonical command vocabulary (values + labels + icons). live-browser.js
|
||||
// builds its action picker from this instead of an inline copy.
|
||||
`window.__IMPECCABLE_VOCAB__ = ${JSON.stringify(vocabulary)};\n`;
|
||||
`window.__IMPECCABLE_VOCAB__ = ${JSON.stringify(vocabulary)};\n` +
|
||||
// Canonical Live chrome inventory from live/ui-surfaces.mjs. live-browser.js
|
||||
// is a classic script and cannot import an ES module at runtime, so the list
|
||||
// is serialized here and read off the global there. Node consumers (this
|
||||
// repo's tests, the impeccable-site Live UI lab) import the module directly,
|
||||
// which is what keeps the two from drifting.
|
||||
`window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` +
|
||||
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`;
|
||||
|
||||
const body = parts.map((part) => {
|
||||
const file = part.file || path.basename(part.path || '');
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Canonical inventory of the Live overlay's UI surfaces: one entry per piece of
|
||||
* chrome Live mounts on the user's page, with the element ids that make it up.
|
||||
*
|
||||
* Single source of truth, consumed by:
|
||||
* - skill/scripts/live/browser-script-parts.mjs — serializes this into
|
||||
* window.__IMPECCABLE_LIVE_UI_SURFACES__ in the /live.js prelude.
|
||||
* - skill/scripts/live-browser.js — publishes it on
|
||||
* window.__IMPECCABLE_LIVE_CHROME_CORE__ for adapters and E2E probes. That
|
||||
* file is served raw and injected as a classic <script>, so it cannot
|
||||
* import this module at runtime; it reads the injected global instead, the
|
||||
* same path live/vocabulary.mjs already takes for the command palette.
|
||||
* - the private impeccable-site repo — site/components/LiveUiGallery.astro
|
||||
* and tests/live-ui-lab.test.mjs import LIVE_UI_SURFACES at build time and
|
||||
* fail the site build when the Live UI lab has no snapshot for a surface
|
||||
* defined here. That guard only guards if it reads this list rather than a
|
||||
* copy the site keeps, so this module must stay importable from Node.
|
||||
* Renaming a key or the module is a breaking change for that build; the
|
||||
* list was briefly inlined into live-browser.js and the site had to parse
|
||||
* it back out with a regex.
|
||||
*
|
||||
* Add a surface here and both the browser bundle and the site lab follow.
|
||||
*/
|
||||
|
||||
/** Id prefix every Live chrome element carries. Mirrored by PREFIX in live-browser.js. */
|
||||
export const LIVE_UI_PREFIX = 'impeccable-live';
|
||||
|
||||
const id = (suffix) => `${LIVE_UI_PREFIX}-${suffix}`;
|
||||
|
||||
/**
|
||||
* The mount contract every Live chrome adapter (DOM, Svelte, ...) satisfies.
|
||||
* Published alongside the surfaces on __IMPECCABLE_LIVE_CHROME_CORE__.
|
||||
*/
|
||||
export const LIVE_CHROME_MOUNT_CONTRACT = Object.freeze(['root', 'transport', 'state', 'actions']);
|
||||
|
||||
export const LIVE_UI_SURFACES = Object.freeze([
|
||||
{
|
||||
key: 'global-bottom-bar',
|
||||
ids: [
|
||||
id('global-bar'), id('global-bar-brand'), id('pick-toggle'), id('insert-toggle'),
|
||||
id('detect-toggle'), id('detect-badge'), id('design-toggle'), id('page-chat'),
|
||||
id('page-chat-input'), id('page-chat-voice'), id('page-chat-send'),
|
||||
],
|
||||
},
|
||||
{ key: 'pending-copy-edit-dock', ids: [id('pending-dock')] },
|
||||
{
|
||||
key: 'element-selection-chrome',
|
||||
ids: [
|
||||
id('highlight'), id('tooltip'), id('bar'), id('selection-pill'), id('input'),
|
||||
id('configure-voice'), id('configure-bar-tooltip'),
|
||||
],
|
||||
},
|
||||
{ key: 'action-picker', ids: [id('picker')] },
|
||||
{ key: 'edit-chrome', ids: [id('edit-badge')] },
|
||||
{ key: 'generating-row', ids: [id('bar'), id('shader')] },
|
||||
{ key: 'variant-cycling-row', ids: [id('bar'), id('params-panel')] },
|
||||
{ key: 'variant-params-panel', ids: [id('params-panel')] },
|
||||
{ key: 'saving-confirmed-rows', ids: [id('bar')] },
|
||||
{
|
||||
key: 'insert-mode-chrome',
|
||||
ids: [
|
||||
id('insert-line'), id('insert-placeholder'), id('placeholder-resize'), id('insert-input'),
|
||||
id('insert-voice'), id('insert-create'), id('insert-create-tooltip'),
|
||||
],
|
||||
},
|
||||
{ key: 'annotation-chrome', ids: [id('annot'), id('annot-svg'), id('annot-pins'), id('annot-clear')] },
|
||||
{ key: 'design-system-panel', ids: [id('design-host')] },
|
||||
{ key: 'toasts-and-errors', ids: [id('toast'), id('mount-error')] },
|
||||
{ key: 'css-isolation-boundary', ids: [id('root')] },
|
||||
].map((surface) => Object.freeze({ ...surface, ids: Object.freeze(surface.ids) })));
|
||||
|
||||
/** Every id any surface owns, de-duplicated, in surface order. */
|
||||
export const LIVE_UI_COMPONENT_IDS = Object.freeze([
|
||||
...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids)),
|
||||
]);
|
||||
@@ -29,6 +29,17 @@
|
||||
* "materials": ["letterpress", "newsprint"], // optional, rendered as tags
|
||||
* "viewport": "one line: the first-viewport composition", // optional
|
||||
* "case": "one line: the fusion verdict, honest", // optional
|
||||
* "verdict": "competitive", // optional routing tier: "wins" |
|
||||
* // "competitive" | "declined". Declined cards
|
||||
* // render demoted after the full cards:
|
||||
* // narrow, quiet, catalog art as a labeled
|
||||
* // thumb, "Adopt anyway" instead of "Build
|
||||
* // this". Still choosable; never deleted.
|
||||
* "kept": "one line: what the direction kept from this declined world",
|
||||
* "raised": [ { "from": "challenger-x", "raise": "one line" } ],
|
||||
* // assigned card only: donations taken from
|
||||
* // declined challengers, rendered as named
|
||||
* // raise lines under the identity row
|
||||
* "risk": "one line: the honest risk", // optional
|
||||
* "body": "fallback prose when the structured fields are absent",
|
||||
* "sketch": ".impeccable/sketches/assigned.webp", // optional; may not exist
|
||||
@@ -41,13 +52,25 @@
|
||||
* }, ...
|
||||
* ],
|
||||
* "reroll": true, // adds a re-roll action (returns {"optionId":"reroll"})
|
||||
* // or { "registers": ["safer", "bolder"] } to add
|
||||
* // the register steers beside it: the answer then
|
||||
* // carries "register" and the agent re-runs
|
||||
* // concept-seed with --register <value>
|
||||
* "canon": true, // adds the "Play it straight" standing exit;
|
||||
* // direction rounds only (returns {"optionId":"canon"})
|
||||
* "canonCard": { ... }, // optional: the standing exit as a full card with the
|
||||
* // same anatomy (label, thesis, palette, sketch, ...);
|
||||
* // rendered last and visually subordinate. Without it,
|
||||
* // canon stays a quiet footer action.
|
||||
* "steer": true // adds a free-text steer field returned with any answer
|
||||
* "steer": true, // adds a free-text steer field returned with any answer
|
||||
* "followup": true // this round's pick is not terminal: the server
|
||||
* // stays open awaiting --update with the next
|
||||
* // round (detached mode only), the page shows a
|
||||
* // loading hand instead of goodbye, and the
|
||||
* // answer carries followup:true so --wait knows
|
||||
* // to keep the table. Use it when a decision has
|
||||
* // a known second half, e.g. direction first,
|
||||
* // then the execution contract.
|
||||
* }
|
||||
*
|
||||
* Options render as large cards: the sketch leads when present, with the
|
||||
@@ -129,6 +152,12 @@ function printAnswer(raw) {
|
||||
if (a.optionId === 'canon') {
|
||||
console.log('CANON CHOSEN: the user picked the category standard on purpose. Ask once for two or three products this should sit alongside; their craft level becomes the quality bar. Execute the canon at full commitment, conventions embraced without irony or smuggled quirk.');
|
||||
}
|
||||
if (a.optionId === 'reroll' && a.register) {
|
||||
console.log(`REGISTER: the user steered the next hand to the ${a.register} register. Re-run concept-seed with the same key, the next --reroll round, and --register ${a.register}, then follow what it prints; the register is the user's steering, never yours to pre-select.`);
|
||||
}
|
||||
if (a.followup && a.optionId !== 'reroll') {
|
||||
console.log('FOLLOWUP OPEN: the table stays open and the page is showing a loading hand. Deliver the next round now with --update --key <key> --payload <file>, then collect it with --wait; never leave the page waiting on a round you have not sent.');
|
||||
}
|
||||
} catch { /* raw answer */ }
|
||||
}
|
||||
|
||||
@@ -144,15 +173,17 @@ if (hasFlag('schema')) {
|
||||
title: 'Choose the visual world',
|
||||
question: 'The roll assigned Fillmore Handbill. Keep it, take an alternate, or re-roll.',
|
||||
options: [
|
||||
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', thesis: 'The gig poster that treats every release like a one-night stand.', palette: ['#e8452c', '#f5d64c', '#1b2a52', '#f3ead8'], materials: ['letterpress', 'split-fountain ink'], viewport: 'A full-bleed dated bill with the product name in warped display type.', risk: 'Reads nostalgic when the type is set timidly.', sketch: '.impeccable/sketches/assigned.webp', hero: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill.webp' },
|
||||
{ id: 'challenger-teletext', label: 'Teletext Service', lineage: 'broadcast teletext magazines', thesis: 'The catalog as a broadcast index: pages, not sections.', case: 'Fuses cleanly: releases map to numbered pages.', sketch: '.impeccable/sketches/challenger-teletext.webp', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
|
||||
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', thesis: 'The gig poster that treats every release like a one-night stand.', palette: ['#e8452c', '#f5d64c', '#1b2a52', '#f3ead8'], materials: ['letterpress', 'split-fountain ink'], viewport: 'A full-bleed dated bill with the product name in warped display type.', risk: 'Reads nostalgic when the type is set timidly.', raised: [{ from: 'challenger-microfiche', raise: 'The bill now owns its whole viewport as one continuous printed sheet.' }], sketch: '.impeccable/sketches/assigned.webp', hero: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill.webp' },
|
||||
{ id: 'model-pick', label: 'The Broadside Ballad', kicker: 'MY PICK', lineage: 'street-sold ballad sheets', thesis: 'Every release printed as the day’s ballad sheet.', risk: 'Also the direction most runs in this category land on.', sketch: '.impeccable/sketches/model-pick.webp' },
|
||||
{ id: 'challenger-teletext', label: 'Teletext Service', verdict: 'competitive', lineage: 'broadcast teletext magazines', thesis: 'The catalog as a broadcast index: pages, not sections.', case: 'Fuses cleanly: releases map to numbered pages; loses narrowly on clarity.', sketch: '.impeccable/sketches/challenger-teletext.webp', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
|
||||
{ id: 'challenger-microfiche', label: 'Microfiche Reader', verdict: 'declined', lineage: 'library microfiche stations', case: 'Fuses poorly: listeners do not identify with archival retrieval.', kept: 'Total environmental commitment.', hero: 'https://impeccable.style/worlds/cards/archives-microfiche-reader-hero.webp' },
|
||||
],
|
||||
reroll: true,
|
||||
reroll: { registers: ['safer', 'bolder'] },
|
||||
canon: true,
|
||||
canonCard: { label: 'The category standard', thesis: 'What this category ships, executed impeccably.', viewport: 'The arrangement a visitor expects, at full craft.', sketch: '.impeccable/sketches/canon.webp' },
|
||||
steer: true,
|
||||
}, null, 2));
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/sketch accept URLs or local paths; sketch slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the sketch, never as the promise of the build. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. A card with no imagery at all has no back; its full read renders on the front, so a text-only round loses nothing. Sketch aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either.');
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/sketch accept URLs or local paths; sketch slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the sketch, never as the promise of the build. verdict routes rendering: "wins" and "competitive" challengers keep full cards, "declined" ones render demoted after them (narrow, quiet, art as a labeled thumb, "Adopt anyway"), with their kept line on the front; the page reorders declined cards to the end on its own. raised on the assigned card renders each donation as a named raise line. Salience parity: when the assigned card declares no sketch (no image generation this round), catalog art on every card demotes to a labeled thumb, so what looks important is the verdict’s call, never rendering luck. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. The pick card is a kicker convention, not a field: kicker "MY PICK" on your top-ranked grounded candidate, one at most, never in the lead slot. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. A card with no imagery at all has no back; its full read renders on the front, so a text-only round loses nothing. Sketch aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either. reroll accepts true or { "registers": ["safer", "bolder"] }: the register buttons steer the next hand along the familiar-to-bold axis, the answer carries "register", and you re-run concept-seed with --register <value> for the next round; offer the registers on direction rounds, and never pre-select one. followup: true keeps the table open after a pick for a second round via --update (direction first, then the execution contract); send the next payload immediately, the page is waiting on it.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -196,12 +227,16 @@ if (hasFlag('wait')) {
|
||||
if (!answered()) { console.log(`WAITING: no answer yet after ${pollSec}s; run --wait --key ${key} again`); process.exit(3); }
|
||||
const collected = fs.readFileSync(answerFile(key), 'utf8').trim();
|
||||
printAnswer(collected);
|
||||
// A re-roll keeps the table open: the server stays alive awaiting --update,
|
||||
// so only the answer file is consumed. Terminal choices clean up fully.
|
||||
let isRerollAnswer = false;
|
||||
try { isRerollAnswer = JSON.parse(collected).optionId === 'reroll'; } catch { /* treat as terminal */ }
|
||||
// A re-roll or a followup-round pick keeps the table open: the server stays
|
||||
// alive awaiting --update, so only the answer file is consumed. Terminal
|
||||
// choices clean up fully.
|
||||
let keepsTableOpen = false;
|
||||
try {
|
||||
const parsedAnswer = JSON.parse(collected);
|
||||
keepsTableOpen = parsedAnswer.optionId === 'reroll' || parsedAnswer.followup === true;
|
||||
} catch { /* treat as terminal */ }
|
||||
try { fs.rmSync(answerFile(key)); } catch { /* already gone */ }
|
||||
if (!isRerollAnswer) { try { fs.rmSync(stateFile(key)); } catch { /* already gone */ } }
|
||||
if (!keepsTableOpen) { try { fs.rmSync(stateFile(key)); } catch { /* already gone */ } }
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -302,6 +337,11 @@ function loadRound(json) {
|
||||
sketchSrc: sketchSrc(option.sketch),
|
||||
});
|
||||
options = parsed.options.map(decorate);
|
||||
// The verdict routes rendering: full cards first, declined cards demoted to
|
||||
// the end of the deck in their own payload order. The reorder happens here
|
||||
// so a payload that interleaves them still renders the weighing's shape.
|
||||
const declined = options.filter((o) => o.verdict === 'declined');
|
||||
if (declined.length) options = [...options.filter((o) => o.verdict !== 'declined'), ...declined];
|
||||
// The standing exit as a full card: same anatomy, reserved id, rendered
|
||||
// subordinate by the page. Without it, canon stays the quiet footer action.
|
||||
if (parsed.canonCard && typeof parsed.canonCard === 'object') {
|
||||
@@ -322,7 +362,19 @@ function page() {
|
||||
// and material tags give a text-only direction an immediate identity that
|
||||
// no generation luck can distort.
|
||||
const fact = (label, value, cls = '') => value ? `<p class="fact${cls ? ` ${cls}` : ''}"><span class="fact-label">${label}</span>${esc(value)}</p>` : '';
|
||||
const hasMedia = (option) => Boolean(option.sketchSrc || option.heroSrc || option.boardSrc);
|
||||
const demoted = (option) => option.verdict === 'declined';
|
||||
// Salience parity: a card's imagery weight is capped by the assigned card's.
|
||||
// When the lead card has no media at all (no image generation this round,
|
||||
// and no catalog art of its own), full-bleed catalog art beside a text-only
|
||||
// assigned card would let rendering luck outvote the weighing: users click
|
||||
// the colorful thing. Declined cards are thumb-only regardless; the verdict
|
||||
// demoted them, and a full-bleed hero would promote them right back.
|
||||
const identityRound = !(options[0] && (options[0].sketchSrc || options[0].heroSrc || options[0].boardSrc));
|
||||
// A declined card never renders a full media face, sketch included: even a
|
||||
// declared sketch would buy back the salience the verdict took away.
|
||||
const faceSketch = (option) => demoted(option) ? null : option.sketchSrc;
|
||||
const thumbOnly = (option) => !faceSketch(option) && Boolean(option.heroSrc || option.boardSrc) && (demoted(option) || identityRound);
|
||||
const hasMedia = (option) => Boolean(faceSketch(option) || ((option.heroSrc || option.boardSrc) && !thumbOnly(option)));
|
||||
// The back exists to keep long facts off a card whose front is an image;
|
||||
// a card with no art has no flip chip to reach it, so it gets no back and
|
||||
// the full read lives on the front instead.
|
||||
@@ -338,6 +390,19 @@ function page() {
|
||||
idBits.push(option.materials.slice(0, 4).map((m) => `<span class="tag">${esc(m)}</span>`).join(''));
|
||||
}
|
||||
if (idBits.length) rows.push(`<div class="identity">${idBits.join('')}</div>`);
|
||||
// Donations from declined challengers render as named raise lines: the
|
||||
// assigned card arrives already raised by the hand it beat, and the raise
|
||||
// is readable, because a raise nobody can read did not happen.
|
||||
if (Array.isArray(option.raised) && option.raised.length) {
|
||||
const nameOf = (id) => options.find((o) => o.id === id)?.label || String(id ?? '');
|
||||
rows.push(`<div class="raises">${option.raised.slice(0, 4).map((r) => `<p class="raise"><span class="fact-label">Raised by ${esc(nameOf(r.from))}</span>${esc(r.raise || r.kept || '')}</p>`).join('')}</div>`);
|
||||
}
|
||||
// Demoted art stays reachable as a labeled thumb: the catalog world
|
||||
// explains where the direction comes from without buying it back the
|
||||
// salience the verdict took away.
|
||||
if (thumbOnly(option)) {
|
||||
rows.push(`<figure class="inspo" title="Inspiration: the world this direction draws from. Your page will not look like this image."><img src="${esc(option.heroSrc || option.boardSrc)}" alt=""><figcaption>inspired by</figcaption></figure>`);
|
||||
}
|
||||
// The front carries only what the choice needs: thesis, identity, and the
|
||||
// honest risk clamped to two lines. First viewport and the case read on
|
||||
// the card's back; once the sketch lands, the first viewport is a picture.
|
||||
@@ -348,6 +413,7 @@ function page() {
|
||||
} else {
|
||||
rows.push(fact('First viewport', option.viewport));
|
||||
rows.push(fact('The case', option.case));
|
||||
rows.push(fact('Kept', option.kept));
|
||||
rows.push(fact('Risk', option.risk));
|
||||
}
|
||||
if (!option.thesis && option.body) rows.push(`<p class="detail">${esc(option.body)}</p>`);
|
||||
@@ -357,6 +423,7 @@ function page() {
|
||||
const backFacts = (option) => [
|
||||
fact('First viewport', option.viewport),
|
||||
fact('The case', option.case),
|
||||
fact('Kept', option.kept),
|
||||
fact('Risk', option.risk),
|
||||
option.body && option.thesis ? `<p class="detail more">${esc(option.body)}</p>` : '',
|
||||
].filter(Boolean).join('\n ');
|
||||
@@ -366,7 +433,10 @@ function page() {
|
||||
<figcaption>inspiration</figcaption>
|
||||
</figure>` : '';
|
||||
const details = hasBack(option) ? flipChip('Details') : '';
|
||||
if (option.sketchSrc) {
|
||||
// Thumb-only art renders inside the body via anatomy(), never as a face,
|
||||
// and a declined card's sketch slot is ignored outright.
|
||||
if (thumbOnly(option)) return '';
|
||||
if (faceSketch(option)) {
|
||||
return `<div class="media sketching" data-sketch="${esc(option.sketchSrc)}">
|
||||
<div class="shimmer"><span class="sketch-note">sketching…</span></div>
|
||||
<img class="sketch" alt="" hidden>
|
||||
@@ -385,17 +455,18 @@ function page() {
|
||||
}
|
||||
return '';
|
||||
};
|
||||
const chooseLabel = (option) => option.isCanon ? 'Play it straight' : demoted(option) ? 'Adopt anyway' : 'Build this';
|
||||
const cards = options.map((option, index) => `
|
||||
<article class="card${option.isCanon ? ' canon' : ''}" style="--fan:${index === 0 ? '0deg' : (index % 2 ? '1.4deg' : '-1.2deg')};--deal:${index * 90}ms" data-id="${esc(option.id)}">
|
||||
<article class="card${option.isCanon ? ' canon' : ''}${demoted(option) ? ' declined' : ''}" style="--fan:${index === 0 ? '0deg' : (index % 2 ? '1.4deg' : '-1.2deg')};--deal:${index * 90}ms" data-id="${esc(option.id)}">
|
||||
<div class="card-inner">
|
||||
<div class="face front${index === 0 ? ' lead' : ''}${media(option) ? '' : ' text-only'}">
|
||||
${option.kicker ? `<span class="kicker">${esc(option.kicker)}</span>` : option.isCanon ? '<span class="kicker standing">The standing door</span>' : ''}
|
||||
${option.kicker ? `<span class="kicker">${esc(option.kicker)}</span>` : demoted(option) ? '<span class="kicker declined-k">Declined</span>' : option.isCanon ? '<span class="kicker standing">The standing door</span>' : ''}
|
||||
${media(option)}
|
||||
<div class="body">
|
||||
${option.lineage ? `<p class="tier">${esc(option.lineage)}</p>` : ''}
|
||||
<h2>${esc(option.label)}</h2>
|
||||
${anatomy(option)}
|
||||
<button class="choose" data-id="${esc(option.id)}">${option.isCanon ? 'Play it straight' : 'Build this'}</button>
|
||||
<button class="choose" data-id="${esc(option.id)}">${chooseLabel(option)}</button>
|
||||
</div>
|
||||
</div>
|
||||
${hasBack(option) ? `<div class="face back${index === 0 ? ' lead' : ''}">
|
||||
@@ -406,7 +477,7 @@ function page() {
|
||||
<div class="body back-body">
|
||||
${option.boardSrc ? `<p class="tier">The full read · ${esc(option.label)}</p>` : ''}
|
||||
${backFacts(option)}
|
||||
<button class="choose" data-id="${esc(option.id)}">${option.isCanon ? 'Play it straight' : 'Build this'}</button>
|
||||
<button class="choose" data-id="${esc(option.id)}">${chooseLabel(option)}</button>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
@@ -585,6 +656,27 @@ function page() {
|
||||
/* The generic .media img display:block would defeat [hidden] and float an
|
||||
empty block over the shimmer; an unloaded sketch must truly not render. */
|
||||
.media img[hidden] { display: none; }
|
||||
/* Declined challengers: the weighing demoted them, so the card is narrower
|
||||
and quieter, its catalog art rides as a labeled thumb in the body, and
|
||||
the action reads "Adopt anyway". Adoptable, never deleted: the demoted
|
||||
row is the hand's proof of judgment. */
|
||||
.grid > .card.declined { flex: 0 0 clamp(15rem, 21vw, 21rem); }
|
||||
.card.declined .face { background: var(--ks-graphite); }
|
||||
.card.declined:hover .face { border-color: var(--ks-text-faint); }
|
||||
.card.declined h2 { font-size: 1rem; color: var(--ks-text); }
|
||||
.kicker.declined-k { background: transparent; border: 1px solid var(--ks-rule); color: var(--ks-text-faint); }
|
||||
.card.declined button.choose { background: transparent; color: var(--ks-text-muted); border: 1px solid var(--ks-rule); font-size: .85rem; padding: 8px 22px; }
|
||||
.card.declined button.choose:hover { background: var(--ks-graphite-2); border-color: var(--ks-text-muted); }
|
||||
/* Thumb-scale inspiration: present, labeled, zoomable, and incapable of
|
||||
outshouting a text-only assigned card. */
|
||||
.inspo { position: relative; flex: none; margin: 2px 0; width: 104px; height: 64px; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; cursor: zoom-in; background: var(--ks-lacquer); }
|
||||
.inspo img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.inspo figcaption { position: absolute; left: 0; right: 0; bottom: 0; font-family: var(--ks-mono); font-size: .48rem; letter-spacing: .16em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 2px 0 3px; background: oklch(7% 0.006 95 / 0.72); }
|
||||
/* Raises: the donations the assigned direction took from the hand it beat,
|
||||
each named for its donor. Patina, not kinpaku: a raise is provenance. */
|
||||
.raises { display: flex; flex-direction: column; gap: 4px; margin: 2px 0; }
|
||||
.raise { font-size: .78rem; color: var(--ks-text-muted); line-height: 1.45; border-left: 2px solid var(--ks-patina); padding-left: 8px; }
|
||||
.raise .fact-label { color: var(--ks-patina); }
|
||||
/* The standing exit as a card: present with full anatomy, never dressed as a
|
||||
contender. Graphite instead of kinpaku, and it never takes the lead ring. */
|
||||
.card.canon .face { border-color: var(--ks-rule); background: var(--ks-graphite); }
|
||||
@@ -597,9 +689,14 @@ function page() {
|
||||
footer { width: 100%; max-width: 90rem; margin: 1.6rem auto 0; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
|
||||
#steer { flex: 1; min-width: 16rem; background: var(--ks-lacquer-raised); color: var(--ks-text); border: 1px solid var(--ks-rule); border-radius: 7px; padding: .6rem .85rem; font: inherit; }
|
||||
#steer:focus { outline: none; border-color: var(--ks-patina); }
|
||||
#reroll { display: inline-flex; align-items: center; align-self: stretch; gap: 8px; padding: 0 16px; font-family: var(--ks-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--ks-kinpaku); background: transparent; border: 1px solid var(--ks-rule); border-radius: 6px; cursor: pointer; transition: border-color .2s ease, color .2s ease; }
|
||||
#reroll:hover { color: var(--ks-kinpaku-pale); border-color: var(--ks-kinpaku-deep); }
|
||||
#reroll svg { width: 15px; height: 15px; }
|
||||
.reroll-btn { display: inline-flex; align-items: center; align-self: stretch; gap: 8px; padding: 0 16px; font-family: var(--ks-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--ks-kinpaku); background: transparent; border: 1px solid var(--ks-rule); border-radius: 6px; cursor: pointer; transition: border-color .2s ease, color .2s ease; }
|
||||
.reroll-btn:hover { color: var(--ks-kinpaku-pale); border-color: var(--ks-kinpaku-deep); }
|
||||
.reroll-btn svg { width: 15px; height: 15px; }
|
||||
.reroll-btn[disabled] { opacity: .4; cursor: default; }
|
||||
/* The register steers read quieter than the plain roll: they are exits from
|
||||
the current register, not the round's main verbs. */
|
||||
#reroll-safer, #reroll-bolder { color: var(--ks-text-muted); min-height: 38px; }
|
||||
#reroll-safer:hover, #reroll-bolder:hover { color: var(--ks-text); border-color: var(--ks-text-faint); }
|
||||
/* The quiet exit: always available, never argued with, visually subordinate
|
||||
to the dealt cards and the re-roll so it reads as the user's own door,
|
||||
not a recommendation. */
|
||||
@@ -644,16 +741,33 @@ function page() {
|
||||
</main>
|
||||
<footer>
|
||||
${payload.steer ? '<input id="steer" placeholder="Optional steer: what should be different or kept?">' : ''}
|
||||
${payload.reroll ? '<button id="reroll"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="8.4" cy="8.4" r="1.5" fill="currentColor"/><circle cx="15.6" cy="8.4" r="1.5" fill="currentColor"/><circle cx="8.4" cy="15.6" r="1.5" fill="currentColor"/><circle cx="15.6" cy="15.6" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg><span>Re-roll</span></button>' : ''}
|
||||
${(() => {
|
||||
if (!payload.reroll) return '';
|
||||
const die = '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="8.4" cy="8.4" r="1.5" fill="currentColor"/><circle cx="15.6" cy="8.4" r="1.5" fill="currentColor"/><circle cx="8.4" cy="15.6" r="1.5" fill="currentColor"/><circle cx="15.6" cy="15.6" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>';
|
||||
const registers = Array.isArray(payload.reroll.registers) ? payload.reroll.registers.filter((r) => r === 'safer' || r === 'bolder') : [];
|
||||
// The registers are the user's steering wheel on the familiar-to-bold
|
||||
// axis; the plain re-roll sits between them so the spatial order matches
|
||||
// the axis it names.
|
||||
const safer = registers.includes('safer') ? '<button class="reroll-btn" id="reroll-safer" title="Deal the familiar register: conventional grounded directions plus the category standard against named competitors"><span>← Safer hand</span></button>' : '';
|
||||
const bolder = registers.includes('bolder') ? '<button class="reroll-btn" id="reroll-bolder" title="Deal foreign forms only, at full commitment"><span>Bolder hand →</span></button>' : '';
|
||||
return `${safer}<button class="reroll-btn" id="reroll">${die}<span>Re-roll</span></button>${bolder}`;
|
||||
})()}
|
||||
${payload.canon && !payload.canonCard ? '<button id="canon" title="Skip the roll: build the page this category ships, executed impeccably">Play it straight</button>' : ''}
|
||||
</footer>
|
||||
<script>
|
||||
const steer = () => document.getElementById('steer')?.value || '';
|
||||
// A followup round's pick keeps the tab: the next round arrives via
|
||||
// --update, so the page shows the loading hand instead of goodbye. Detached
|
||||
// mode only, and the page must agree with the server: a blocking server
|
||||
// exits on any pick and has no update channel, so a followup payload there
|
||||
// still gets the goodbye screen, never a loading hand nothing will resolve.
|
||||
const FOLLOWUP = ${payload.followup === true && Boolean(detachedKey) ? 'true' : 'false'};
|
||||
const beat = () => { try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
|
||||
beat();
|
||||
setInterval(beat, 5000);
|
||||
async function answer(optionId) {
|
||||
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
|
||||
if (FOLLOWUP) { await awaitNextRound(); return; }
|
||||
document.body.innerHTML = '<div class="done"><svg viewBox="0 0 24 24" width="38" height="38" fill="oklch(84% 0.19 80.46)" aria-hidden="true"><path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/><path d="M16.5 2.5 L19 2.5 Q21.5 2.5 21.5 5 L21.5 19 Q21.5 21.5 19 21.5 L8.5 21.5 Z"/></svg>Choice recorded. The agent is resuming; you can close this tab.</div>';
|
||||
}
|
||||
document.querySelectorAll('button.choose').forEach(b => b.addEventListener('click', () => answer(b.dataset.id)));
|
||||
@@ -759,15 +873,15 @@ function page() {
|
||||
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) artFailed(img);
|
||||
else img.addEventListener('error', () => artFailed(img), { once: true });
|
||||
});
|
||||
// A broken inspiration PIP just leaves; nothing depends on it.
|
||||
document.querySelectorAll('.pip img').forEach(img => {
|
||||
const gone = () => img.closest('.pip')?.remove();
|
||||
// A broken inspiration PIP or thumb just leaves; nothing depends on it.
|
||||
document.querySelectorAll('.pip img, .inspo img').forEach(img => {
|
||||
const gone = () => img.closest('.pip, .inspo')?.remove();
|
||||
if (img.complete && img.naturalWidth === 0) gone();
|
||||
else img.addEventListener('error', gone, { once: true });
|
||||
});
|
||||
|
||||
// Inspiration PIP opens the full catalog card in the lightbox.
|
||||
document.querySelectorAll('.pip').forEach(p => p.addEventListener('click', (e) => {
|
||||
// Inspiration PIP or body thumb opens the full catalog card in the lightbox.
|
||||
document.querySelectorAll('.pip, .inspo').forEach(p => p.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const img = p.querySelector('img');
|
||||
if (!img) return;
|
||||
@@ -814,7 +928,7 @@ function page() {
|
||||
const ambient = document.getElementById('ambient');
|
||||
document.querySelectorAll('.card').forEach(card => {
|
||||
card.addEventListener('mouseenter', () => {
|
||||
const art = card.querySelector('.face.front .media img:not([hidden])') || card.querySelector('.face.front .pip img');
|
||||
const art = card.querySelector('.face.front .media img:not([hidden])') || card.querySelector('.face.front .pip img') || card.querySelector('.face.front .inspo img');
|
||||
if (!art || !art.getAttribute('src')) return;
|
||||
ambient.style.backgroundImage = 'url("' + art.getAttribute('src') + '")'; ambient.style.opacity = '1';
|
||||
});
|
||||
@@ -862,8 +976,11 @@ function page() {
|
||||
lightbox.addEventListener('click', closeLightbox);
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !lightbox.hidden) closeLightbox(); });
|
||||
document.getElementById('canon')?.addEventListener('click', () => answer('canon'));
|
||||
document.getElementById('reroll')?.addEventListener('click', async () => {
|
||||
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer() }) });
|
||||
const dealAgain = async (register) => {
|
||||
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
|
||||
await awaitNextRound();
|
||||
};
|
||||
async function awaitNextRound() {
|
||||
const grid = document.querySelector('.grid');
|
||||
const cardsNow = [...grid.querySelectorAll('.card')];
|
||||
const g = grid.getBoundingClientRect();
|
||||
@@ -880,14 +997,17 @@ function page() {
|
||||
}
|
||||
const cardHeight = cardsNow[0] ? cardsNow[0].getBoundingClientRect().height : 0;
|
||||
grid.innerHTML = cardsNow.map(() => '<article class="card skeleton"' + (cardHeight ? ' style="height:' + cardHeight + 'px"' : '') + '><div class="card-inner"><div class="face front"><div class="media"><div class="shimmer"></div></div><div class="body"><div class="line tier w40"></div><div class="line title w70"></div><div class="line w90"></div><div class="line w80"></div><div class="line w60"></div><div class="line button"></div></div></div></div></article>').join('');
|
||||
document.getElementById('reroll')?.setAttribute('disabled', '');
|
||||
document.querySelectorAll('.reroll-btn').forEach(b => b.setAttribute('disabled', ''));
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
const status = await (await fetch('/next-status')).json();
|
||||
if (status.ready) { clearInterval(poll); location.reload(); }
|
||||
} catch { /* server briefly busy */ }
|
||||
}, 1200);
|
||||
});
|
||||
}
|
||||
document.getElementById('reroll')?.addEventListener('click', () => dealAgain());
|
||||
document.getElementById('reroll-safer')?.addEventListener('click', () => dealAgain('safer'));
|
||||
document.getElementById('reroll-bolder')?.addEventListener('click', () => dealAgain('bolder'));
|
||||
</script>`;
|
||||
}
|
||||
|
||||
@@ -944,22 +1064,29 @@ const server = http.createServer((req, res) => {
|
||||
let parsed = {};
|
||||
try { parsed = JSON.parse(body); } catch { /* empty steer */ }
|
||||
const chosen = options.find((o) => o.id === parsed.optionId);
|
||||
const isReroll = parsed.optionId === 'reroll';
|
||||
// A followup round's pick is not terminal: the table stays open for the
|
||||
// next round (--update), exactly like a re-roll. Detached mode only;
|
||||
// the blocking mode has no update channel, so its picks stay terminal.
|
||||
const followupOpen = Boolean(detachedKey) && payload.followup === true && !isReroll;
|
||||
const answer = JSON.stringify({
|
||||
optionId: parsed.optionId ?? null,
|
||||
steer: parsed.steer ?? '',
|
||||
...(isReroll && (parsed.register === 'safer' || parsed.register === 'bolder') ? { register: parsed.register } : {}),
|
||||
...(followupOpen ? { followup: true } : {}),
|
||||
...(chosen?.hero || chosen?.board ? { hero: chosen.hero ?? null, board: chosen.board ?? null } : {}),
|
||||
...(chosen?.sketch ? { sketch: chosen.sketch } : {}),
|
||||
});
|
||||
const isReroll = parsed.optionId === 'reroll';
|
||||
if (detachedKey) {
|
||||
fs.mkdirSync(QUESTION_DIR, { recursive: true });
|
||||
fs.writeFileSync(answerFile(detachedKey), answer + '\n');
|
||||
} else {
|
||||
printAnswer(answer);
|
||||
}
|
||||
// A re-roll in detached mode keeps the table open: the client shows a
|
||||
// loading hand and reloads when --update delivers the next round.
|
||||
if (!(isReroll && detachedKey)) setTimeout(() => process.exit(0), 150);
|
||||
// A re-roll or followup pick in detached mode keeps the table open: the
|
||||
// client shows a loading hand and reloads when --update delivers the
|
||||
// next round.
|
||||
if (!((isReroll || followupOpen) && detachedKey)) setTimeout(() => process.exit(0), 150);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user