@bongos/core 1.25.15 → 1.25.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -529,6 +529,10 @@
529
529
  } catch (err) { return null; }
530
530
  }
531
531
 
532
+ // The hall's one completion tick (24x24, stroked, currentColor): the setup flow's step
533
+ // marks and the Genesis checklist both draw it, so a done thing looks done the same way.
534
+ const CHECK_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>';
535
+
532
536
  const OTB = {
533
537
  escapeHtml, sleep, $, fmtNum, num, fmtInt, fmtCompact, fmtDate, rankLabel, toast, moduleOn, disciplines,
534
538
  plainMessage, toastKind, TOAST_MS, // task 1005086 — one toast, a duration per kind, no server address
@@ -542,6 +546,7 @@
542
546
  splitCriterion,
543
547
  categoryChip, // task 1003276 — shared by the Goals page, the Tasks board and the task page
544
548
  hubOrigin, // task 1003485 — the hub account page's origin, for Settings and the profile nudge
549
+ CHECK_SVG, // task 1005110 — the one tick, setup-gate.js and genesis-home.js
545
550
  };
546
551
  if (typeof window !== 'undefined') window.OTB = OTB;
547
552
  // Node: the hall libs that also load under require() (hall-kit.js, task-lib.js, …)
@@ -190,9 +190,10 @@
190
190
 
191
191
  // task 1005109: Copy prompt on each task still to do, so the founder pastes it into
192
192
  // Claude without leaving Genesis. The Tasks board's own button (work.js), and only that
193
- // one verb: the owner declined a Claim button here. A finished task offers nothing.
193
+ // one verb: the owner declined a Claim button here. Only an OPEN task offers it: a done
194
+ // one has nothing left, and a locked one cannot be claimed yet (task 1005110).
194
195
  function copyButtonHtml(t) {
195
- if (t.finished) return '';
196
+ if (t.state !== 'open') return '';
196
197
  return '<button class="task__copy" type="button" data-genesis-copy="' + esc(t.id) + '"'
197
198
  + ' title="Copy the task prompt to your clipboard" aria-label="Copy prompt for task ' + esc(t.id) + '">'
198
199
  + '<span class="task__copy-icon" aria-hidden="true">📋</span></button>';
@@ -212,16 +213,30 @@
212
213
  return lib.copyTaskPrompt(t, btn, toast);
213
214
  }
214
215
 
216
+ // task 1005110: one checklist row in one of three states, as the server decided them
217
+ // (shapeGenesis taskState): done (a filled circle with the tick), open (an empty
218
+ // circle, its status and Copy prompt) or locked (a dashed circle and what it waits on,
219
+ // no Copy prompt: the claim would be refused).
220
+ function taskRowHtml(t) {
221
+ var state = t.state === 'done' || t.state === 'locked' ? t.state : 'open';
222
+ var check = window.OTB.CHECK_SVG || '';
223
+ var mark = '<span class="genesis__mark" aria-hidden="true">' + (state === 'done' ? check : '') + '</span>';
224
+ var said = { done: 'Done', open: 'To do', locked: 'Locked' }[state];
225
+ var after = (t.waits_on || []).map(function (w) { return w.title; }).filter(Boolean);
226
+ var tail = state === 'locked'
227
+ ? '<span class="genesis__task-wait">' + esc(after.length ? 'after ' + after.join(', ') : 'after an earlier task') + '</span>'
228
+ : window.OTB.statusPill(t.status, { small: true, extraClass: 'genesis__task-st' }) + copyButtonHtml(t);
229
+ return '<li class="genesis__task is-' + state + '">' + mark
230
+ + '<span class="sr-only">' + said + ': </span>'
231
+ + '<a class="genesis__task-t" href="/task/' + encodeURIComponent(t.id) + '">' + esc(t.title) + '</a>'
232
+ + tail + '</li>';
233
+ }
234
+
215
235
  function nowHtml(g, viewerId) {
216
236
  var s = g.stages.find(function (x) { return x.state === 'now'; });
217
237
  if (!s) return '';
218
238
  var tasks = s.tasks.length
219
- ? '<ul class="genesis__tasks">' + s.tasks.map(function (t) {
220
- return '<li class="genesis__task' + (t.finished ? ' is-finished' : '') + '">'
221
- + '<a class="genesis__task-t" href="/task/' + encodeURIComponent(t.id) + '">' + esc(t.title) + '</a>'
222
- + window.OTB.statusPill(t.status, { small: true, extraClass: 'genesis__task-st' })
223
- + copyButtonHtml(t) + '</li>';
224
- }).join('') + '</ul>'
239
+ ? '<ul class="genesis__tasks">' + s.tasks.map(taskRowHtml).join('') + '</ul>'
225
240
  : '<p class="genesis__note">No tasks for this stage yet.</p>';
226
241
  return '<section class="genesis__now" aria-labelledby="genesis-now-h">'
227
242
  + '<h3 id="genesis-now-h" class="genesis__now-h">This stage’s work · ' + esc(s.label) + '</h3>'
@@ -20,7 +20,7 @@
20
20
 
21
21
  function esc(s) { return window.OTB.escapeHtml(s); }
22
22
 
23
- var CHECK = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>';
23
+ var CHECK = window.OTB.CHECK_SVG; // the hall's one tick (dom-utils.js), shared with the Genesis checklist
24
24
 
25
25
  function stepDetail(step) {
26
26
  if (step.id === 'cli_setup') {
@@ -1054,7 +1054,27 @@ html[data-theme="dark"] .gplate__light { display: none; }
1054
1054
  .genesis__stage.is-later { opacity: 0.6; }
1055
1055
  .genesis__now-h { margin: 0 0 4px; font-size: 15px; font-weight: 500; }
1056
1056
  .genesis__task-t { flex: 1; min-width: 0; padding: 3px 0; }
1057
- .genesis__task.is-finished .genesis__task-t { color: var(--ink-faint); text-decoration: line-through; }
1057
+ /* task 1005110: the stage's work reads as a checklist. Done is a filled circle with the
1058
+ hall's tick (the setup flow's mark), open an empty circle, locked a dashed one with
1059
+ what it waits on. Never struck through: a finished task was earned, not cancelled. */
1060
+ .genesis__mark {
1061
+ flex: none;
1062
+ display: inline-flex;
1063
+ align-items: center;
1064
+ justify-content: center;
1065
+ width: 24px;
1066
+ height: 24px;
1067
+ border-radius: var(--ds-radius-pill); /* round on purpose: the owner asked for a check filling a circle */
1068
+ border: 1.5px solid var(--rule);
1069
+ color: var(--ok);
1070
+ }
1071
+ .genesis__mark svg { width: 14px; height: 14px; }
1072
+ .genesis__task.is-done .genesis__mark { border-color: transparent; background: var(--ok-weak); }
1073
+ .genesis__task.is-done .genesis__task-t { color: var(--ink-soft); }
1074
+ .genesis__task.is-open .genesis__mark { border-color: var(--accent); }
1075
+ .genesis__task.is-locked .genesis__mark { border-style: dashed; }
1076
+ .genesis__task.is-locked .genesis__task-t { color: var(--ink-faint); }
1077
+ .genesis__task-wait { flex: none; max-width: 40%; font-size: var(--ds-text-xs); color: var(--ink-soft); text-align: right; }
1058
1078
  .genesis__alive { display: grid; justify-items: center; text-align: center; gap: 10px; padding: 12px 0; }
1059
1079
  .genesis__alive-plates { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }
1060
1080
  .genesis__alive-h { margin: 6px 0 0; font-size: 28px; font-weight: 400; }
@@ -144,7 +144,7 @@ function kickoffChecklist(spec = {}, { provisioned = false, firstSignIn = false
144
144
  const GENESIS_STEPS = Object.freeze({
145
145
  scope: [
146
146
  { title: 'Write the project scope: what we are making, for whom, and what is out of scope',
147
- description: 'Genesis stage 1 (scope). One page the whole team can read: the thing being made, who it is for, what done looks like for the first version, and what is deliberately left out. When it is written, Goals and the Roadmap wake.' },
147
+ description: 'Genesis stage 1 (scope). The project interview: what you are creating and why, who it is for and who you want building it with you, what success looks like in two years, and what everyone else in this area is getting wrong. Every future goal and task is planned from your answers, and "not sure yet" is a fine answer you can come back to. You are the author and you choose how to work: be guided through the questions, write it yourself with the questions beside you, or have your own AI session walk you through them (it asks and pushes back; you decide) — switch any time, nothing is lost. The one-page scope is then drafted from your answers. Run `npx bongos genesis show scope` to see the questions and your answers, or `npx bongos genesis assist scope` for the brief to give your AI session. When it is written, Goals and the Roadmap wake.' },
148
148
  ],
149
149
  strategize: [
150
150
  { title: 'Write out the strategy, or link the one you already have',
@@ -168,9 +168,19 @@ const GENESIS_EXISTING_FIRST = {
168
168
  description: 'Genesis stage 1 (scope), for a project that already exists. Before writing anything new, link what is already there, so the scope is written from it rather than beside it.',
169
169
  };
170
170
 
171
- // genesisChecklist({ existing }) → { stageKey: [{ title, description, kind, discipline, ref }] }
171
+ // GENESIS_AFTER — "this step follows that one", by ref (task 1005110). A step listed
172
+ // here is filed with a real dependency edge on the step it names, so it cannot be
173
+ // claimed (DEPS_NOT_SHIPPED) and the genesis home draws it locked until that one
174
+ // ships. Only where the order is real: a stage with no entry here is all open at once.
175
+ // Today that is brownfield scope alone — the scope is written FROM what was gathered.
176
+ const GENESIS_AFTER = Object.freeze({
177
+ 'genesis:scope': 'genesis:scope:existing',
178
+ });
179
+
180
+ // genesisChecklist({ existing }) → { stageKey: [{ title, description, kind, discipline, ref, after }] }
172
181
  // in genesis order. `ref` is the stable per-task key the seeder dedups on, so filing
173
- // twice files nothing new. Pure — no I/O.
182
+ // twice files nothing new. `after` is the ref of the step this one waits for, set only
183
+ // when that step is in the same list (GENESIS_AFTER), else null. Pure — no I/O.
174
184
  function genesisChecklist({ existing = false } = {}) {
175
185
  const out = {};
176
186
  for (const [stage, steps] of Object.entries(GENESIS_STEPS)) {
@@ -181,6 +191,10 @@ function genesisChecklist({ existing = false } = {}) {
181
191
  ...t, kind: 'decision', discipline: 'artist', ref: t === GENESIS_EXISTING_FIRST ? 'genesis:scope:existing' : `genesis:${stage}`,
182
192
  }));
183
193
  }
194
+ for (const list of Object.values(out)) {
195
+ const refs = new Set(list.map((t) => t.ref));
196
+ for (const t of list) t.after = refs.has(GENESIS_AFTER[t.ref]) ? GENESIS_AFTER[t.ref] : null;
197
+ }
184
198
  return out;
185
199
  }
186
200
 
@@ -85,6 +85,7 @@ async function seedGenesisStages({ builderId = null, existing = false } = {}, de
85
85
  const pool = deps.pool || api.pool;
86
86
  const insert = deps.createTask || ((row) => db.createTask(row));
87
87
  const find = deps.findTaskBySourceRef || ((q) => db.findTaskBySourceRef(q));
88
+ const addDep = deps.addTaskDependency || ((id, dep) => db.addTaskDependency(id, dep));
88
89
  const { rows: versions } = await pool.query(FIRST_VERSION_SQL);
89
90
  if (!versions.length) return { seeded: 0, skipped: 'no_version' };
90
91
  const versionId = versions[0].id;
@@ -109,18 +110,39 @@ async function seedGenesisStages({ builderId = null, existing = false } = {}, de
109
110
  seeded += 1;
110
111
  }
111
112
  }
113
+ // task 1005110: the order genesisChecklist declares becomes a real edge, so the claim
114
+ // gate refuses the later step (DEPS_NOT_SHIPPED) until the earlier one ships. Written
115
+ // for steps already filed too (ON CONFLICT DO NOTHING), so a re-run repairs a missing edge.
116
+ for (const steps of Object.values(genesisChecklist({ existing }))) {
117
+ for (const t of steps) {
118
+ if (!t.after) continue;
119
+ const [me, before] = await Promise.all([
120
+ find({ source: GENESIS_SOURCE, sourceRef: t.ref }),
121
+ find({ source: GENESIS_SOURCE, sourceRef: t.after }),
122
+ ]);
123
+ if (me && before) await addDep(me.id, before.id);
124
+ }
125
+ }
112
126
  return { seeded, versionId };
113
127
  }
114
128
 
115
- // genesisStageTasks(deps) → { stageKey: [{ id, title, status, description }] } for every
116
- // genesis task on the board, grouped by the stage its source_ref names. The genesis home
117
- // lists them, and a stage close hands their ids to the Board Room's finished-tasks gate.
129
+ // genesisStageTasks(deps) → { stageKey: [{ id, title, status, description, waits_on }] } for
130
+ // every genesis task on the board, grouped by the stage its source_ref names. The genesis
131
+ // home lists them, and a stage close hands their ids to the Board Room's finished-tasks gate.
132
+ // `waits_on` is [{ id, title }] of the tasks it depends on that have not shipped: the claim
133
+ // gate's own rule (unsatisfiedDeps, abandoned still blocks), so a row drawn locked is one
134
+ // POST /claims would refuse (task 1005110).
118
135
  // The description rides along so each row's Copy prompt carries the task's own words
119
136
  // (task 1005109); the read stays bounded, it is only the few seeded genesis tasks.
120
137
  async function genesisStageTasks(deps = {}) {
121
138
  const pool = deps.pool || api.pool;
122
139
  const { rows } = await pool.query(
123
- `SELECT id, title, status, description, source_ref FROM tasks WHERE source = $1 ORDER BY id ASC`,
140
+ `SELECT t.id, t.title, t.status, t.description, t.source_ref,
141
+ COALESCE((SELECT json_agg(json_build_object('id', x.id, 'title', x.title) ORDER BY x.id)
142
+ FROM dependencies d JOIN tasks x ON x.id = d.to_id
143
+ WHERE d.from_kind = 'task' AND d.from_id = t.id
144
+ AND d.to_kind = 'task' AND x.status <> 'shipped'), '[]'::json) AS waits_on
145
+ FROM tasks t WHERE t.source = $1 ORDER BY t.id ASC`,
124
146
  [GENESIS_SOURCE],
125
147
  );
126
148
  const out = {};
@@ -129,6 +151,7 @@ async function genesisStageTasks(deps = {}) {
129
151
  if (!stage) continue;
130
152
  (out[stage] = out[stage] || []).push({
131
153
  id: String(r.id), title: r.title, status: r.status, description: r.description || '',
154
+ waits_on: (Array.isArray(r.waits_on) ? r.waits_on : []).map((w) => ({ id: String(w.id), title: w.title })),
132
155
  });
133
156
  }
134
157
  return out;
@@ -143,10 +143,19 @@ function readPlanValue(raw) {
143
143
 
144
144
  const FINISHED = Object.freeze(['shipped', 'abandoned']); // the Board Room's own gate, mirrored for display
145
145
 
146
+ // taskState(t) — one stage task as the checklist draws it (task 1005110).
147
+ function taskState(t) {
148
+ const finished = FINISHED.includes(t.status);
149
+ const waits = finished ? [] : (Array.isArray(t.waits_on) ? t.waits_on : []);
150
+ return { ...t, finished, waits_on: waits, state: finished ? 'done' : waits.length ? 'locked' : 'open' };
151
+ }
152
+
146
153
  // shapeGenesis({ mode, plan, tasks, board, stageAt }) — PURE. The genesis home's view.
147
154
  // mode foundingModeFor's shape (non-null)
148
155
  // plan the saved plan or null (deciding)
149
- // tasks { stage: [{ id, title, status, description }] }
156
+ // tasks { stage: [{ id, title, status, description, waits_on }] }
157
+ // Each task comes back with `state`: 'done' (finished), 'locked' (waits on an unshipped
158
+ // task, named in `waits_on`) or 'open' — decided here, never re-derived in a browser.
150
159
  // board { stage: genesisStageStatus row } or {}
151
160
  // stageAt { stage: ISO } — when each stage began, for its due date
152
161
  function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {} }) {
@@ -154,7 +163,7 @@ function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {} }) {
154
163
  const stages = PROPER.map((key) => {
155
164
  const info = STAGE_INFO[key];
156
165
  const idx = fm.STAGES.indexOf(key);
157
- const list = (tasks[key] || []).map((t) => ({ ...t, finished: FINISHED.includes(t.status) }));
166
+ const list = (tasks[key] || []).map((t) => taskState(t));
158
167
  const b = board[key] || null;
159
168
  const allFinished = list.length > 0 && list.every((t) => t.finished);
160
169
  const onBoard = !!(b && (b.state === 'sitting' || b.state === 'passed'));
@@ -40,7 +40,7 @@
40
40
  // un-answer appends a history row, so the founder can see what an answer used to say.
41
41
  //
42
42
  // WHICH STAGES. A stage adopts the pattern by declaring its questions in QUESTIONS.
43
- // Strategize does here; scope's guided door is its project interview (task 1005114);
43
+ // Strategize does (task 1005116); scope's guided door is its project interview (task 1005114);
44
44
  // charter, legalize and legislation adopt it when they are next touched. A stage with
45
45
  // no questions still takes a free page and a link.
46
46
  //
@@ -59,8 +59,30 @@ const LIMITS = Object.freeze({ answer: 4000, page: 20000, link: 500 });
59
59
 
60
60
  // Each adopted stage's questions, in the order the guided door asks them. `id` is
61
61
  // stable: a stored answer names it, so never rename or reuse one. `hint` is the shape
62
- // of a good answer, shown under the question in every door.
62
+ // of a good answer, shown under the question in every door. `group` (optional) is a
63
+ // heading the guided door shows above the questions that share it.
64
+ //
65
+ // SCOPE IS THE PROJECT INTERVIEW (task 1005114): the questions every later goal and task
66
+ // should be planned inside — what this is for, who it is for, and the bet — rather than
67
+ // V1's edges, which is what came back thin before. The owner's starter set, as written;
68
+ // the list is "not final" and the owner invited more. These are prose questions; the
69
+ // closed-vocabulary ones (team shape, geography, entity, rewards) are the hub's project
70
+ // detail and are not repeated here.
63
71
  const QUESTIONS = Object.freeze({
72
+ scope: Object.freeze([
73
+ Object.freeze({ id: 'what_creating', group: 'What and why', ask: 'In a few sentences, what are you creating with this project?',
74
+ hint: 'Say it the way you would to a friend. What exists when this is done that does not exist now?' }),
75
+ Object.freeze({ id: 'goal_impact', group: 'What and why', ask: 'What is your goal with it? What impact do you want it to have?',
76
+ hint: 'Why this is worth doing at all, and for whom it changes something.' }),
77
+ Object.freeze({ id: 'success_two_years', group: 'What and why', ask: 'In two years, what does success look like?',
78
+ hint: 'Something you could point at: who is using it, what they do with it, what it has become.' }),
79
+ Object.freeze({ id: 'reach_who', group: 'Who', ask: 'Who do you want to reach with this project?',
80
+ hint: 'The people it is ultimately for, not only the first ones. Be as specific as you can.' }),
81
+ Object.freeze({ id: 'work_with_who', group: 'Who', ask: 'Who do you want working on it with you?',
82
+ hint: 'The kind of people you hope join: their skills, how they work, what they care about.' }),
83
+ Object.freeze({ id: 'others_wrong', group: 'The bet', ask: 'What is everyone else in this area getting wrong?',
84
+ hint: 'The thing you believe that others in this space do not. This is the bet the project makes.' }),
85
+ ]),
64
86
  strategize: Object.freeze([
65
87
  Object.freeze({ id: 'big_pieces', ask: 'What are the big pieces, and in what order do they get built?',
66
88
  hint: 'Three to six pieces is plenty. Say why that order: what has to exist before what.' }),
@@ -118,7 +140,7 @@ function shapeStage(stage, work, answers = []) {
118
140
  const questions = questionsFor(stage).map((q) => {
119
141
  const a = byId.get(q.id);
120
142
  return {
121
- id: q.id, ask: q.ask, hint: q.hint,
143
+ id: q.id, ask: q.ask, hint: q.hint, group: q.group || null,
122
144
  answer: a ? {
123
145
  state: a.state, text: a.answer_md || null, origin: a.origin,
124
146
  // A carried-over answer is shown as such until the founder makes it theirs.
@@ -196,11 +218,12 @@ function assistPrompt(stage) {
196
218
  '- Offer two or three options when I am stuck, never one confident answer.',
197
219
  '- "Not sure yet" is a fine answer. Record it as that rather than filling the gap for me.',
198
220
  '- Record an answer only after I say it is mine, in my words (you may tidy the wording; read it back first).',
221
+ '- Once I have answered, you may offer a draft page built only from my answers. I edit it; save it only when I say it is mine.',
199
222
  '',
200
223
  ];
201
224
  if (qs.length) {
202
- lines.push('The questions:');
203
- qs.forEach((q, i) => lines.push(`${i + 1}. ${q.ask} (${q.hint})`));
225
+ lines.push('The questions (the id in brackets is what you record the answer under):');
226
+ qs.forEach((q, i) => lines.push(`${i + 1}. [${q.id}] ${q.ask} (${q.hint})`));
204
227
  lines.push('');
205
228
  }
206
229
  lines.push('To see what I have already said: npx bongos genesis show ' + stage);
@@ -0,0 +1,245 @@
1
+ /* assets/guide-scene.css — the start-a-project guide as a scene in the game (task 1005275).
2
+
3
+ The scene wears the game's look, Helm (DESIGN.md "Helm: the hall's look"): a night panel
4
+ closed left and right by the strong ice hairline, names in the name voice (condensed
5
+ wide capitals), what you press cut at two corners, progress as skewed segments, and the
6
+ one accent bar on the thing to do next. It stands on the landing, so it spends the
7
+ landing's tokens, never the hall's literals: the night and the ice are mixes of the
8
+ pack's --sea into its --bg and --ink (the Fifteen Rule), and they flip with the world's
9
+ own mode tier, so this sheet writes no dark rule and no :root. Every value is a var()
10
+ or a color-mix() of the fifteen; black with alpha is the scrim. Motion is two short
11
+ transitions and the waiting dots, all stopped by world.css's reduced-motion switch.
12
+ Layout: the plan beside the talk. Every layout value that changes on a phone is a
13
+ --gd-* name read with its desktop value as the fallback, and the landing's ONE
14
+ breakpoint (900px, DESIGN.md "Responsive"; the @media (max-width:900px) body block in
15
+ index.html) restates those names: one column, the frame the whole screen, the plan
16
+ opening over the talk from the Plan button. So this sheet adds no breakpoint, and its
17
+ inset edges are named once here (--gd-edge*) rather than typed into each rule. */
18
+ .gd {
19
+ --gd-night: color-mix(in srgb, var(--sea) 9%, var(--bg));
20
+ --gd-panel: color-mix(in srgb, var(--sea) 13%, var(--bg-card));
21
+ --gd-ice: color-mix(in srgb, var(--sea) 12%, var(--ink));
22
+ --gd-rule: color-mix(in srgb, var(--gd-ice) 40%, transparent);
23
+ --gd-rule-soft: color-mix(in srgb, var(--gd-ice) 20%, transparent);
24
+ --gd-glass: color-mix(in srgb, var(--gd-ice) 9%, transparent);
25
+ --gd-strong: 1.5px;
26
+ --gd-name: "Barlow Condensed", var(--font-display);
27
+ --gd-body: "Barlow", var(--font-body);
28
+ --gd-cut: 12px;
29
+ --gd-cut-sm: 8px;
30
+ --gd-cut-path: polygon(var(--gd-cut) 0, 100% 0, 100% calc(100% - var(--gd-cut)), calc(100% - var(--gd-cut)) 100%, 0 100%, 0 var(--gd-cut));
31
+ --gd-cut-path-sm: polygon(var(--gd-cut-sm) 0, 100% 0, 100% calc(100% - var(--gd-cut-sm)), calc(100% - var(--gd-cut-sm)) 100%, 0 100%, 0 var(--gd-cut-sm));
32
+ --gd-focus: inset 0 0 0 2px var(--focus), inset 0 0 0 4px var(--gd-night);
33
+ /* the hairline drawn inside a cut shape (a border would be clipped by the cut) */
34
+ --gd-edge: inset 0 0 0 1px var(--gd-rule);
35
+ --gd-edge-soft: inset 0 0 0 1px var(--gd-rule-soft);
36
+ --gd-seg-edge: inset 0 0 0 var(--gd-strong) var(--gd-rule);
37
+
38
+ position: fixed; inset: 0; z-index: var(--ds-z-modal, 160);
39
+ display: flex; align-items: center; justify-content: center;
40
+ padding: var(--gd-pad, var(--ds-space-md));
41
+ background: rgba(0, 0, 0, .62);
42
+ -webkit-backdrop-filter: blur(6px) saturate(.7); backdrop-filter: blur(6px) saturate(.7);
43
+ color: var(--gd-ice); font-family: var(--gd-body); font-size: var(--ds-text-xl); line-height: 1.5;
44
+ }
45
+ .gd[hidden] { display: none; }
46
+ .gd .vh { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
47
+ html.gd-open, html.gd-open body { overflow: hidden; }
48
+
49
+ .gd__frame {
50
+ position: relative; box-sizing: border-box;
51
+ width: min(100%, var(--gd-frame-w, 1080px)); height: min(100%, var(--gd-frame-h, 760px));
52
+ display: grid; grid-template-columns: var(--gd-cols, minmax(0, 1fr) 340px);
53
+ background: var(--gd-night);
54
+ border: 0 solid var(--gd-rule); border-left-width: var(--gd-side, var(--gd-strong)); border-right-width: var(--gd-side, var(--gd-strong));
55
+ box-shadow: var(--ds-shadow-pop);
56
+ }
57
+
58
+ /* ── the talk ── */
59
+ .gd__talk { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
60
+ .gd__head {
61
+ display: flex; align-items: center; justify-content: space-between; gap: var(--ds-space-sm);
62
+ padding: var(--ds-space-sm) var(--gd-gutter, var(--ds-space-md));
63
+ border-bottom: var(--gd-strong) solid var(--gd-rule);
64
+ }
65
+ .gd__title, .gd__planh {
66
+ margin: 0; font-family: var(--gd-name); font-weight: 500; font-size: var(--ds-text-3xl);
67
+ letter-spacing: .24em; text-transform: uppercase; line-height: 1.2; color: var(--ink);
68
+ }
69
+ .gd__x {
70
+ flex: none; display: inline-grid; place-items: center; width: 40px; height: 40px; padding: 0;
71
+ background: transparent; color: var(--gd-ice); border: 0; cursor: pointer;
72
+ clip-path: var(--gd-cut-path-sm);
73
+ transition: background-color var(--ds-dur-fast) var(--ds-ease);
74
+ }
75
+ .gd__x svg { width: 18px; height: 18px; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; fill: none; }
76
+ .gd__x:hover { background: var(--gd-glass); }
77
+
78
+ /* the guide: the sprite in a cut frame, its name, the count as words and segments */
79
+ .gd__who-row {
80
+ display: flex; align-items: center; gap: var(--ds-space-sm);
81
+ padding: var(--ds-space-sm) var(--gd-gutter, var(--ds-space-md));
82
+ border-bottom: var(--ds-line, 1px) solid var(--gd-rule-soft);
83
+ }
84
+ .gd__face {
85
+ position: relative; flex: none; width: var(--gd-face, 72px); height: var(--gd-face, 72px); display: grid; place-items: center;
86
+ background: var(--gd-panel); clip-path: var(--gd-cut-path-sm);
87
+ box-shadow: var(--gd-edge);
88
+ }
89
+ .gd__face img { width: var(--gd-face-img, 64px); height: var(--gd-face-img, 64px); image-rendering: pixelated; }
90
+ .gd__face img[hidden] { display: none; }
91
+ .gd__meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
92
+ .gd__name {
93
+ margin: 0; font-family: var(--gd-name); font-weight: 600; font-size: var(--ds-text-xl);
94
+ letter-spacing: .2em; text-transform: uppercase; color: var(--ink);
95
+ }
96
+ .gd__left { margin: 0; font-size: var(--ds-text-md); color: var(--gd-ice); font-variant-numeric: tabular-nums; }
97
+ .gd__left[hidden], .gd__segs[hidden] { display: none; }
98
+ .gd__segs { display: flex; flex-wrap: wrap; gap: var(--ds-space-hair); padding-left: 4px; }
99
+ /* a message still to send is an outline; a sent one is filled ice (the Helm segment) */
100
+ .gd__seg {
101
+ width: 22px; height: 8px; transform: skewX(-28deg);
102
+ box-shadow: var(--gd-seg-edge);
103
+ }
104
+ .gd__seg--used { background: var(--gd-ice); box-shadow: none; }
105
+
106
+ /* the conversation */
107
+ .gd__log {
108
+ flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
109
+ margin: 0; padding: var(--ds-space-md) var(--gd-gutter, var(--ds-space-md)); list-style: none;
110
+ display: flex; flex-direction: column; gap: var(--ds-space-sm);
111
+ }
112
+ .gd__msg { display: flex; flex-direction: column; gap: 2px; max-width: 46rem; }
113
+ .gd__who {
114
+ font-family: var(--gd-name); font-size: var(--ds-text-xs); font-weight: 500;
115
+ letter-spacing: .2em; text-transform: uppercase; color: var(--gd-ice);
116
+ }
117
+ .gd__words { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; text-wrap: pretty; color: var(--ink); }
118
+ .gd__msg--visitor { align-self: flex-end; align-items: flex-end; }
119
+ .gd__msg--visitor .gd__words {
120
+ padding: var(--ds-space-hair) var(--ds-space-sm);
121
+ background: var(--gd-panel); clip-path: var(--gd-cut-path-sm);
122
+ box-shadow: var(--gd-edge-soft);
123
+ }
124
+ .gd__msg--note .gd__words {
125
+ padding-top: var(--ds-space-hair); border-top: var(--ds-line, 1px) solid var(--gd-rule-soft);
126
+ color: var(--gd-ice); font-size: var(--ds-text-md);
127
+ }
128
+ /* waiting: three dots that rise in turn; at rest (reduced motion) they simply sit */
129
+ .gd__dot {
130
+ display: inline-block; width: 6px; height: 6px; margin-right: 6px; border-radius: var(--ds-radius-pill);
131
+ background: var(--gd-ice); opacity: .5;
132
+ animation: gd-wait 1.2s var(--ds-ease) infinite;
133
+ }
134
+ .gd__dot:nth-child(3) { animation-delay: .15s; }
135
+ .gd__dot:nth-child(4) { animation-delay: .3s; }
136
+ @keyframes gd-wait { 0%, 60%, 100% { opacity: .35; transform: none; } 30% { opacity: 1; transform: translateY(-3px); } }
137
+
138
+ .gd__err {
139
+ margin: 0 var(--ds-space-md) var(--ds-space-hair); padding: var(--ds-space-hair) var(--ds-space-sm);
140
+ border-left: var(--gd-strong) solid var(--err); background: var(--gd-glass);
141
+ color: var(--ink); font-size: var(--ds-text-md);
142
+ }
143
+ .gd__err[hidden] { display: none; }
144
+
145
+ /* the composer */
146
+ .gd__say {
147
+ display: flex; align-items: flex-end; gap: var(--ds-space-hair); flex-wrap: wrap;
148
+ padding: var(--ds-space-sm) var(--gd-gutter, var(--ds-space-md)) var(--ds-space-md);
149
+ border-top: var(--gd-strong) solid var(--gd-rule);
150
+ }
151
+ .gd__say[hidden] { display: none; }
152
+ .gd__text {
153
+ flex: 1 1 16rem; min-width: 0; min-height: 48px; max-height: 9.5rem; resize: vertical; box-sizing: border-box;
154
+ padding: 11px var(--ds-space-sm);
155
+ background: var(--gd-panel); color: var(--ink); caret-color: var(--accent);
156
+ border: var(--ds-line, 1px) solid var(--gd-rule); border-radius: 0;
157
+ font: inherit; font-size: var(--ds-text-xl);
158
+ }
159
+ .gd__text::placeholder { color: var(--gd-ice); opacity: .8; }
160
+ .gd__text:focus-visible { outline: 2px solid var(--focus); outline-offset: 0; box-shadow: none; border-color: transparent; }
161
+ .gd__text:disabled { opacity: .7; }
162
+ .gd__hp { position: absolute; left: -9999px; width: 1px; height: 1px; opacity: 0; }
163
+ /* under the box: end the chat early for the plan, or leave for sign-up */
164
+ .gd__sayfoot { flex-basis: 100%; display: flex; align-items: center; justify-content: space-between; gap: var(--ds-space-sm); flex-wrap: wrap; }
165
+ .gd__skip {
166
+ display: inline-flex; align-items: center; padding: 2px 0; min-height: 24px;
167
+ color: var(--gd-ice); font-size: var(--ds-text-md); text-decoration: underline; text-underline-offset: 3px;
168
+ }
169
+ .gd__skip:hover { color: var(--ink); }
170
+
171
+ /* what you press: cut, in the name voice. The primary is the one accent bar. */
172
+ .gd__send, .gd__go, .gd__planbtn {
173
+ display: inline-flex; align-items: center; justify-content: center; gap: var(--ds-space-hair);
174
+ min-height: 48px; padding: 0 var(--ds-space-md); box-sizing: border-box; border: 0; cursor: pointer;
175
+ font-family: var(--gd-name); font-size: var(--ds-text-xl); font-weight: 600;
176
+ letter-spacing: .2em; text-transform: uppercase; text-decoration: none;
177
+ clip-path: var(--gd-cut-path);
178
+ transition: background-color var(--ds-dur-fast) var(--ds-ease);
179
+ }
180
+ .gd__send, .gd__go { background: var(--accent); color: var(--accent-on); }
181
+ .gd__send:hover, .gd__go:hover { background: var(--accent-lift); color: var(--accent-on); }
182
+ .gd__send:disabled { background: var(--gd-panel); color: var(--gd-ice); cursor: default; }
183
+ /* the Plan button is the one-column layout's only (the plan stands beside the talk above it) */
184
+ .gd__planbtn { display: var(--gd-planbtn, none); flex: none; background: var(--gd-panel); color: var(--ink); box-shadow: var(--gd-edge); min-height: 40px; padding: 0 var(--ds-space-sm); font-size: var(--ds-text-md); }
185
+ .gd__planbtn:hover { background: var(--gd-glass); }
186
+ /* a cut control's focus is the inset two-tone ring, since a clip-path clips an outline */
187
+ .gd__x:focus-visible, .gd__send:focus-visible, .gd__go:focus-visible, .gd__planbtn:focus-visible {
188
+ outline: 0; box-shadow: var(--gd-focus);
189
+ }
190
+ .gd__skip:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; box-shadow: none; }
191
+
192
+ /* the end: one line and the way on */
193
+ .gd__end {
194
+ display: flex; align-items: center; justify-content: flex-end; gap: var(--ds-space-sm); flex-wrap: wrap;
195
+ padding: var(--ds-space-sm) var(--gd-gutter, var(--ds-space-md)) var(--ds-space-md);
196
+ border-top: var(--gd-strong) solid var(--gd-rule);
197
+ }
198
+ .gd__end[hidden] { display: none; }
199
+ .gd__go { flex: 0 1 auto; width: var(--gd-go-w, auto); box-shadow: var(--accent-glow); }
200
+ .gd__go[hidden] { display: none; }
201
+ /* the quiet press: a cut ghost in the name voice (Make my plan) */
202
+ .gd__more {
203
+ display: inline-flex; align-items: center; justify-content: center; min-height: 40px; padding: 0 var(--ds-space-sm);
204
+ background: transparent; color: var(--ink); border: 0; cursor: pointer;
205
+ box-shadow: var(--gd-edge); clip-path: var(--gd-cut-path-sm);
206
+ font-family: var(--gd-name); font-size: var(--ds-text-md); font-weight: 600; letter-spacing: .2em; text-transform: uppercase;
207
+ transition: background-color var(--ds-dur-fast) var(--ds-ease);
208
+ }
209
+ .gd__more:hover { background: var(--gd-glass); }
210
+ .gd__more:focus-visible { outline: 0; box-shadow: var(--gd-focus); }
211
+ .gd__more[hidden] { display: none; }
212
+
213
+ /* ── the plan ── */
214
+ .gd__plan {
215
+ display: var(--gd-plan-show, flex); flex-direction: column; min-height: 0;
216
+ position: var(--gd-plan-pos, static); inset: 0; z-index: 1;
217
+ background: var(--gd-panel);
218
+ border-left: var(--gd-plan-edge, var(--ds-line, 1px)) solid var(--gd-rule-soft);
219
+ }
220
+ /* one column: the Plan button lays the plan over the talk */
221
+ .gd--plan .gd__plan { display: flex; }
222
+ .gd--plan .gd__talk { visibility: var(--gd-talk-under-plan, visible); }
223
+ .gd__planhead {
224
+ display: flex; align-items: center; justify-content: space-between; gap: var(--ds-space-sm);
225
+ padding: var(--ds-space-sm) var(--ds-space-md);
226
+ border-bottom: var(--gd-strong) solid var(--gd-rule);
227
+ min-height: 72px; box-sizing: border-box;
228
+ }
229
+ .gd__planhead .gd__x { display: var(--gd-planx, none); }
230
+ .gd__planbody { flex: 1; min-height: 0; overflow-y: auto; padding: var(--ds-space-md); display: flex; flex-direction: column; gap: var(--ds-space-hair); }
231
+ .gd__planwait { margin: 0; color: var(--gd-ice); font-size: var(--ds-text-lg); }
232
+ .gd__plansum { margin: 0; color: var(--ink); font-size: var(--ds-text-lg); text-wrap: pretty; }
233
+ .gd__planh4 {
234
+ margin: var(--ds-space-sm) 0 0; font-family: var(--gd-name); font-size: var(--ds-text-xs); font-weight: 500;
235
+ letter-spacing: .2em; text-transform: uppercase; color: var(--gd-ice);
236
+ }
237
+ .gd__planlist { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; }
238
+ .gd__plannote, .gd__saved { margin: 0; color: var(--gd-ice); font-size: var(--ds-text-md); }
239
+ .gd__plannote { margin-top: var(--ds-space-sm); padding-top: var(--ds-space-hair); border-top: var(--ds-line, 1px) solid var(--gd-rule-soft); }
240
+ .gd__saved { padding: var(--ds-space-sm) var(--ds-space-md); border-top: var(--ds-line, 1px) solid var(--gd-rule-soft); }
241
+ .gd__saved[hidden] { display: none; }
242
+ .gd__planlist li {
243
+ padding: var(--ds-space-hair) 0; border-top: var(--ds-line, 1px) solid var(--gd-rule-soft);
244
+ color: var(--ink); font-size: var(--ds-text-lg);
245
+ }