@fasl-work/caos-app-shell 0.12.0 → 0.13.1

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.
@@ -110,10 +110,16 @@ declare function AppShell({ config, children }: {
110
110
  children: ReactNode;
111
111
  }): react.JSX.Element;
112
112
 
113
+ /** The route every product lands on: the Introduction, whose executive view opens first (shell 0.13.0). */
114
+ declare const LANDING_ROUTE = "/introduction";
115
+ /** The route of the App (the tool); it was the root before 0.13.0 (the `Landing` component forwards old links). */
116
+ declare const APP_ROUTE = "/app";
113
117
  /**
114
- * The six routes every CAOS product carries, in this order (ADR-0016 s3, ADR-0069 s7, the product quality bar):
115
- * the App (the tool, landing) and the five documentation routes. One definition, so the header, the router and the
116
- * gate read the same list and no product retypes it.
118
+ * The six routes every CAOS product carries, ALWAYS in this order (Felipe, 2026-10-13): App, Introduction,
119
+ * Methodology, Implementation, Experiments, Benchmark. The App stays the leftmost tab; the product lands on the
120
+ * Introduction (its executive view first) through `Landing`, which never changes the order (0.13.0 had moved the
121
+ * Introduction first; 0.13.1 restores the App). One definition, so the header, the router and the gate read the same
122
+ * list and no product retypes it.
117
123
  */
118
124
  declare const STANDARD_ROUTES: ShellRoute[];
119
125
 
@@ -132,4 +138,4 @@ declare const LANG_STORAGE_KEY = "caos.lang";
132
138
  */
133
139
  declare const THEME_BOOT_SCRIPT: string;
134
140
 
135
- export { AppShell as A, LANG_STORAGE_KEY as L, STANDARD_ROUTES as S, THEME_BOOT_SCRIPT as T, type ArchTab as a, type ArchitectureConfig as b, ArchitectureModal as c, type ShellConfig as d, type ShellRoute as e, THEME_STORAGE_KEY as f, validateArchitectureConfig as v };
141
+ export { APP_ROUTE as A, LANDING_ROUTE as L, STANDARD_ROUTES as S, THEME_BOOT_SCRIPT as T, AppShell as a, type ArchTab as b, type ArchitectureConfig as c, ArchitectureModal as d, LANG_STORAGE_KEY as e, type ShellConfig as f, type ShellRoute as g, THEME_STORAGE_KEY as h, validateArchitectureConfig as v };
package/dist/keys.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { L as LANG_STORAGE_KEY, S as STANDARD_ROUTES, T as THEME_BOOT_SCRIPT, f as THEME_STORAGE_KEY } from './keys-CnvqkckF.js';
1
+ export { A as APP_ROUTE, L as LANDING_ROUTE, e as LANG_STORAGE_KEY, S as STANDARD_ROUTES, T as THEME_BOOT_SCRIPT, h as THEME_STORAGE_KEY } from './keys-DDQGijwQ.js';
2
2
  import 'react';
3
3
  import './text-BlY47nIt.js';
4
4
  import 'zustand';
package/dist/keys.js CHANGED
@@ -1 +1 @@
1
- export { LANG_STORAGE_KEY, STANDARD_ROUTES, THEME_BOOT_SCRIPT, THEME_STORAGE_KEY } from './chunk-X6Y5CDUD.js';
1
+ export { APP_ROUTE, LANDING_ROUTE, LANG_STORAGE_KEY, STANDARD_ROUTES, THEME_BOOT_SCRIPT, THEME_STORAGE_KEY } from './chunk-33WTUQ7Q.js';
package/gate/areas.mjs CHANGED
@@ -170,39 +170,79 @@ export const EXECUTIVE_FLOORS = ['executive', 'executive-view', 'initiative', 'o
170
170
 
171
171
  // ------------------------------------------------------------------------------------------------ the executive view
172
172
  // The executive view of the Introduction is the shell's ExecutiveView fed by the product's content/executive.json
173
- // (conventions/problem-context-first.md). Its structure is `src/content/executive.schema.json`; its writing limits are
174
- // here AND in `src/content/executive.ts` (test/executive.test.ts keeps the two equal): the gate measures the rendered
175
- // page, the component validates the data.
176
- export const EXEC_SCHEMA_VERSION = '1.0.0';
173
+ // (conventions/problem-context-first.md). Its structure and its floors are `src/content/executive.schema.json`; its writing
174
+ // limits, its floors and its 4 C's are here AND in `src/content/executive.ts` (test/executive.test.ts keeps them equal): the
175
+ // gate measures the rendered page, the component validates the data.
176
+ export const EXEC_SCHEMA_VERSION = '2.0.0';
177
177
  export const EXEC_LIMITS = {
178
178
  maxWords: 30,
179
- maxAnswerSentences: 2,
179
+ minParagraphWords: 25,
180
180
  equation: /\$|\\\(|\\\[|\\[a-zA-Z]+/,
181
181
  citations: [/\(\s*Source/i, /\[\d+\]/, /\(\s*ECB/, /\(\w[^()]*\d{4}\)/],
182
182
  minLabelPx: 9,
183
+ fileCitations: [/\bfile\s*\d/i, /\barchivo\s*\d/i, /\.pdf\b/i, /\bPublished on\b/i],
184
+ promotional: [/\bour\s+clients?\b/i, /\bnuestros?\s+clientes?\b/i, /\bcontact\s+us\b/i, /\bcontáctenos\b/i, /\bbuy\b/i, /\bcomprar\b/i, /\bprices?\b/i, /\bprecios?\b/i, /\boffers?\b/i, /\bofertas?\b/i],
183
185
  };
184
- /** The four sections of the view: title and rubric question (Felipe's rubric, verbatim). */
186
+ /** The floors of the view as [min, max] counts of rows; a copy of EXEC_FLOORS of src/content/executive.ts (and of the schema's minItems and maxItems). */
187
+ export const EXEC_FLOORS = {
188
+ headline_figures: [2, 3],
189
+ 'pain.paragraphs': [2, 3],
190
+ 'pain.bearers': [3, 5],
191
+ 'pain.figures': [2, 4],
192
+ 'pain.causes': [3, 5],
193
+ 'context.paragraphs': [1, 2],
194
+ 'context.forces': [3, 4],
195
+ 'context.evidence': [1, 4],
196
+ 'situation.roles': [3, 5],
197
+ 'situation.state_of_the_art': [3, 5],
198
+ 'trends.items': [3, 5],
199
+ 'proposal.paragraphs': [1, 2],
200
+ 'proposal.four_cs': [4, 4],
201
+ 'proposal.actions': [3, 6],
202
+ 'proposal.risks': [1, 4],
203
+ 'proposal.unknowns': [1, 4],
204
+ };
205
+ /** The sub-tab (1 to 5) each floor is shown in; 0 is the landing block, shown above the sub-tabs in every state. */
206
+ const floorTab = (key) => ({ headline_figures: 0, pain: 1, context: 2, situation: 3, trends: 4, proposal: 5 })[key.split('.')[0]];
207
+ /** The sub-tabs of the view: the shell's own titles. A copy of EXEC_SECTION_DEFS of src/content/executive.ts; test/executive.test.ts keeps the two equal. */
185
208
  export const EXEC_SECTIONS = [
186
- { title: { en: 'Industry & Exec Tailoring', es: 'Industria y foco ejecutivo' }, question: { en: 'Built for a C-suite exec?', es: '¿Pensado para un ejecutivo de primer nivel?' } },
187
- { title: { en: 'AI Transformation Story', es: 'Historia de transformación con IA' }, question: { en: 'Outcomes, not a feature tour', es: 'Resultados, no un recorrido de funciones' } },
188
- { title: { en: '4Cs Positioning', es: 'Posicionamiento 4C' }, question: { en: 'Value story, not features dump', es: 'Historia de valor, no lista de funciones' } },
189
- { title: { en: 'Delivery & Credibility', es: 'Entrega y credibilidad' }, question: { en: 'Trust them in front of a CxO?', es: '¿Confiaría en ellos frente a un ejecutivo?' } },
209
+ { title: { en: 'The pain', es: 'El problema' } },
210
+ { title: { en: 'The context', es: 'El contexto' } },
211
+ { title: { en: 'The current situation', es: 'La situación actual' } },
212
+ { title: { en: 'The trends', es: 'Las tendencias' } },
213
+ { title: { en: 'The proposal', es: 'La propuesta' } },
190
214
  ];
191
- /** The illustrations in the order of the page and the section each sits in. */
215
+ /** What the page must never print: the rubric's four labels and four questions, in both languages (Felipe, 2026-10-13: the rubric gives ideas and language, not content). */
216
+ export const EXEC_RUBRIC_LABELS = ['Industry & Exec Tailoring', 'Industria y foco ejecutivo', 'AI Transformation Story', 'Historia de transformación con IA', '4Cs Positioning', 'Posicionamiento 4C', 'Delivery & Credibility', 'Entrega y credibilidad'];
217
+ export const EXEC_RUBRIC_QUESTIONS = ['Built for a C-suite exec?', '¿Pensado para un ejecutivo de primer nivel?', 'Outcomes, not a feature tour', 'Resultados, no un recorrido de funciones', 'Value story, not features dump', 'Historia de valor, no lista de funciones', 'Trust them in front of a CxO?', '¿Confiaría en ellos frente a un ejecutivo?'];
218
+ /** The illustrations and the sub-tab each sits in. */
192
219
  export const EXEC_ILLUSTRATIONS = [
193
- { name: 'initiative', section: 1 },
194
- { name: 'problem', section: 1 },
195
- { name: 'solution', section: 2 },
220
+ { name: 'pain', section: 1 },
221
+ { name: 'situation', section: 3 },
222
+ { name: 'proposal', section: 5 },
223
+ ];
224
+ /** The 4 C's with the names and definitions Felipe gave on 2026-10-13 (the same as FOUR_CS of src/content/executive.ts; test/executive.test.ts keeps them equal). */
225
+ export const EXEC_FOUR_CS = [
226
+ { name: { en: 'Choice', es: 'Elección' }, definition: { en: 'flexibility across proprietary and open models.', es: 'flexibilidad entre modelos propietarios y abiertos.' } },
227
+ { name: { en: 'Context', es: 'Contexto' }, definition: { en: 'AI grounded in business meaning, so decisions are accurate.', es: 'IA anclada en el significado del negocio, para que las decisiones sean exactas.' } },
228
+ { name: { en: 'Cost', es: 'Costo' }, definition: { en: 'visibility of what the AI spends.', es: 'visibilidad de lo que gasta la IA.' } },
229
+ { name: { en: 'Control', es: 'Control' }, definition: { en: 'one governance over data, models and agents.', es: 'una sola gobernanza sobre datos, modelos y agentes.' } },
196
230
  ];
197
- export const EXEC_PENDING = { en: "Pending: the 4 C's are defined by the product owner", es: 'Pendiente: las 4 C las define el responsable del producto' };
231
+ /** The onward links under the sources (a copy of ONWARD_LINKS of src/content/executive.ts). */
232
+ export const EXEC_LINKS = {
233
+ technical: { en: 'How the work explores this problem', es: 'Cómo explora el trabajo este problema' },
234
+ app: { en: 'Open the App', es: 'Abrir la App' },
235
+ };
198
236
 
199
- /** How many sentences a field of the view may hold: one for the single-sentence fields, two for an answer, no limit otherwise. */
237
+ /** How many sentences a field of the view may hold: one for the single-sentence fields, two for the key statement, no limit otherwise. */
200
238
  export const execSentenceLimit = (path) =>
201
- /^(initiative|vision)$|^outcomes\[\d+\]\.proof\.statement$|^four_cs\[\d+\]\.exec_problem$|^illustrations\.\w+\.caption$/.test(path)
239
+ /^title$|\.key_message$|^context\.evidence\[\d+\]\.statement$|^situation\.interaction$|\.signal\.statement$|^proposal\.four_cs\[\d+\]\.applies$|^proposal\.actions\[\d+\]\.(action|check)$|^proposal\.(risks|unknowns)\[\d+\]$|^proposal\.handoff$|^illustrations\.\w+\.caption$/.test(path)
202
240
  ? 1
203
- : /^dialogue\[\d+\]\.answer$/.test(path)
204
- ? EXEC_LIMITS.maxAnswerSentences
241
+ : path === 'key_statement'
242
+ ? 2
205
243
  : undefined;
244
+ /** The fewest words a field of the view may hold: a paragraph needs EXEC_LIMITS.minParagraphWords. */
245
+ export const execMinWords = (path) => (/\.paragraphs\[\d+\]$/.test(path) ? EXEC_LIMITS.minParagraphWords : undefined);
206
246
 
207
247
  const sentencesOf = (text) =>
208
248
  text
@@ -211,67 +251,200 @@ const sentencesOf = (text) =>
211
251
  .filter(Boolean);
212
252
  const wordsIn = (text) => text.split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w)).length;
213
253
 
254
+ /** The problems with the title or the note of one source note (the same rules as sourceTextProblems of src/content/executive.ts). */
255
+ export function execSourceTextProblems(text, maxSentences) {
256
+ const out = [];
257
+ const sentences = sentencesOf(text);
258
+ for (const sentence of sentences) {
259
+ const n = wordsIn(sentence);
260
+ if (n > EXEC_LIMITS.maxWords) out.push(`a sentence of ${n} words; the limit is ${EXEC_LIMITS.maxWords}`);
261
+ }
262
+ if (maxSentences !== undefined && sentences.length > maxSentences) out.push(maxSentences === 1 ? `must be one sentence, has ${sentences.length}` : `has ${sentences.length} sentences; the limit is ${maxSentences}`);
263
+ const written = /https?:\/\//i.test(text);
264
+ for (const re of EXEC_LIMITS.fileCitations) {
265
+ if (re.source.includes('pdf') && written) continue;
266
+ const m = re.exec(text);
267
+ if (m) out.push(`cites a file name or number in place of the link ("${m[0]}"); give the publication as the title and its https link as the url`);
268
+ }
269
+ return out;
270
+ }
271
+
214
272
  /**
215
- * The problems of the executive view in one language, from what the page showed (`G.docPage().executive` of
216
- * gate/inpage.mjs). Pure. `facts` is null or {count: 0} when the page renders no ExecutiveView.
273
+ * The problems of the executive view in one language, from what the page showed in every state of the walk in which
274
+ * the executive view was open (`G.docPage().executive` of gate/inpage.mjs, one entry per state, the first being the
275
+ * state the page opened on). Pure. The five sections are sub-tabs, so one state shows one section: the sections, fields,
276
+ * rows, illustrations and figures of the states are gathered and checked together, and a sub-tab the walk never opened is a
277
+ * failure. An entry is null or {count: 0} when the page renders no ExecutiveView.
217
278
  * @returns {string[]} one text per problem
218
279
  */
219
- export function executiveFailures(facts, lang) {
280
+ export function executiveFailures(list, lang) {
220
281
  const L = lang.toUpperCase();
221
282
  const out = [];
222
283
  const bad = (m) => out.push(`Introduction, executive view (${L}): ${m}`);
223
- if (!facts || facts.count === 0) {
284
+ const states = (list ?? []).filter(Boolean);
285
+ const first = states[0];
286
+ if (!first || first.count === 0) {
224
287
  bad("no ExecutiveView ([data-executive-view]); the executive view is the shell's ExecutiveView fed by content/executive.json (docs/06_executive-view.md)");
225
288
  return out;
226
289
  }
227
- if (facts.count > 1) bad(`${facts.count} ExecutiveView elements; exactly 1 is required`);
228
- if (facts.schema !== EXEC_SCHEMA_VERSION) bad(`the view declares schema "${facts.schema ?? 'none'}", expected "${EXEC_SCHEMA_VERSION}"`);
229
- if (facts.valid !== 'true') {
230
- bad(`the data file is not valid in the page: ${String(facts.errors || 'no reason given').slice(0, 400)}`);
231
- return out;
290
+ for (const facts of states) {
291
+ if (facts.count === 0) continue;
292
+ if (facts.count > 1) bad(`${facts.count} ExecutiveView elements; exactly 1 is required`);
293
+ if (facts.schema !== EXEC_SCHEMA_VERSION) bad(`the view declares schema "${facts.schema ?? 'none'}", expected "${EXEC_SCHEMA_VERSION}"`);
294
+ if (facts.valid !== 'true') {
295
+ bad(`the data file is not valid in the page: ${String(facts.errors || 'no reason given').slice(0, 400)}`);
296
+ return [...new Set(out)];
297
+ }
298
+ if (facts.docSections > 0) bad(`${facts.docSections} DocSection(s) placed in the executive view (${facts.docSectionTitles.join(', ')}); the view has exactly its sections and takes none`);
299
+ if (facts.strangers.length > 0) bad(`${facts.strangers.length} block(s) beside the ExecutiveView (${facts.strangers.slice(0, 3).map((x) => `"${x}"`).join(', ')}); the view is the whole of the executive view`);
300
+ if (!facts.inOpenView) bad('the ExecutiveView is not in the open executive view');
301
+ }
302
+
303
+ // the landing block above the sub-tabs: the title, the key statement, two or three headline figures
304
+ const landing = first.landing;
305
+ if (!landing || !landing.present) bad('no landing block above the sub-tabs (the title, the key statement and the headline figures)');
306
+ else {
307
+ if (!landing.title) bad('the landing block has no title');
308
+ if (!landing.statement) bad('the landing block has no key statement');
309
+ }
310
+
311
+ // the sections are the sub-tabs, in order, the first selected, one section shown at a time
312
+ const tabs = first.tabs ?? [];
313
+ if (tabs.length === 0) bad('the sections are not sub-tabs: the view has no sub-tab list; the sections must be the sub-tabs of SubTabs, one per section (shell 0.13.0)');
314
+ else {
315
+ if (tabs.length !== EXEC_SECTIONS.length) bad(`the executive view shows ${tabs.length} sub-tab(s), exactly ${EXEC_SECTIONS.length} are required (one per section)`);
316
+ EXEC_SECTIONS.forEach((def, i) => {
317
+ if (tabs[i] !== undefined && tabs[i] !== def.title[lang]) bad(`sub-tab ${i + 1} is labelled "${tabs[i]}", expected "${def.title[lang]}"`);
318
+ });
319
+ if (first.activeTab !== 0) bad('the first sub-tab is not the one selected when the view opens');
320
+ }
321
+ for (const facts of states) {
322
+ if (facts.count === 0) continue;
323
+ if (facts.sections.length > 1) bad(`${facts.sections.length} sections shown at once; the view shows one section at a time, each in its own sub-tab`);
324
+ }
325
+ const bySection = new Map();
326
+ for (const facts of states) {
327
+ if (facts.count === 0) continue;
328
+ for (const sec of facts.sections) {
329
+ if (!bySection.has(sec.n)) bySection.set(sec.n, { sec, facts });
330
+ if (tabs.length > 0 && facts.sections.length === 1 && facts.activeTab !== sec.n - 1) bad(`section ${sec.n} sits in sub-tab ${facts.activeTab + 1}, it belongs in sub-tab ${sec.n}`);
331
+ }
232
332
  }
233
- if (facts.docSections > 0) bad(`${facts.docSections} DocSection(s) placed in the executive view (${facts.docSectionTitles.join(', ')}); the view has exactly its four sections and takes none`);
234
- if (facts.strangers.length > 0) bad(`${facts.strangers.length} block(s) beside the ExecutiveView (${facts.strangers.slice(0, 3).map((x) => `"${x}"`).join(', ')}); the view is the whole of the executive view`);
235
- if (!facts.inOpenView) bad('the ExecutiveView is not in the open executive view');
236
- // the four sections, in order, with their titles and questions
237
- if (facts.sections.length !== 4) bad(`${facts.sections.length} section(s), exactly 4 are required`);
238
333
  EXEC_SECTIONS.forEach((def, i) => {
239
- const got = facts.sections[i];
240
- if (!got) return;
241
- if (got.n !== i + 1) bad(`section ${i + 1} is out of order (it declares ${got.n})`);
242
- if (got.title !== def.title[lang]) bad(`section ${i + 1} is titled "${got.title}", expected "${def.title[lang]}"`);
243
- if (got.question !== def.question[lang]) bad(`section ${i + 1} asks "${got.question}", expected "${def.question[lang]}"`);
334
+ const got = bySection.get(i + 1);
335
+ if (!got) {
336
+ bad(`sub-tab "${def.title[lang]}" was never visited, so its section was never checked`);
337
+ return;
338
+ }
339
+ if (got.sec.title !== def.title[lang]) bad(`section ${i + 1} is titled "${got.sec.title}", expected "${def.title[lang]}"`);
340
+ // every sub-tab opens with its key message line, which a reader can read on its own
341
+ if (!got.sec.keyFirst) bad(`section ${i + 1} ("${def.title[lang]}") does not open with its key message line`);
342
+ else if (!got.sec.keyMessage) bad(`section ${i + 1} ("${def.title[lang]}") has an empty key message line`);
244
343
  });
245
- const s3 = facts.sections[2];
246
- if (s3) {
247
- if (s3.pending === 1 && s3.rows === 0) {
248
- if (s3.pendingText !== EXEC_PENDING[lang]) bad(`section 3 declares the pending state "${s3.pendingText}", expected "${EXEC_PENDING[lang]}"`);
249
- if (s3.pendingState !== 'unavailable') bad('section 3 is pending without data-state="unavailable"');
250
- } else if (s3.rows !== 4) bad(`section 3 holds ${s3.rows} row(s) of the 4 C's, exactly 4 are required (or the pending state)`);
344
+ for (const n of bySection.keys()) if (n < 1 || n > EXEC_SECTIONS.length) bad(`section ${n} is not one of the ${EXEC_SECTIONS.length} sections`);
345
+
346
+ // the floors: the rows each part of the view shows, at least the minimum and at most the maximum
347
+ const visited = new Set([0, ...bySection.keys()]);
348
+ for (const [key, [min, max]] of Object.entries(EXEC_FLOORS)) {
349
+ if (!visited.has(floorTab(key))) continue;
350
+ const n = Math.max(0, ...states.map((x) => x.items?.[key] ?? 0));
351
+ if (n < min) bad(`${key}: ${n} row(s) are shown, at least ${min} are required`);
352
+ else if (n > max) bad(`${key}: ${n} row(s) are shown, at most ${max} are allowed`);
353
+ }
354
+
355
+ // the 4 C's of the proposal: Felipe's names and definitions, in order, each applied to the proposal
356
+ const s5 = bySection.get(5);
357
+ if (s5) {
358
+ const cs = s5.facts.cs ?? [];
359
+ if (cs.length !== 4) bad(`the proposal holds ${cs.length} row(s) of the 4 C's, exactly 4 are required`);
360
+ EXEC_FOUR_CS.forEach((c, i) => {
361
+ const got = cs[i];
362
+ if (!got) return;
363
+ if (got.name !== c.name[lang]) bad(`C ${i + 1} is named "${got.name}", expected "${c.name[lang]}" (the 4 C's in Felipe's order: ${EXEC_FOUR_CS.map((x) => x.name[lang]).join(', ')})`);
364
+ else if (got.def !== c.definition[lang]) bad(`C ${i + 1} ("${c.name[lang]}") defines itself as "${got.def}", expected "${c.definition[lang]}"`);
365
+ else if (!got.applies) bad(`C ${i + 1} ("${c.name[lang]}") says nothing about what it means for the proposal`);
366
+ });
367
+ }
368
+
369
+ // the view takes the full width of the document column (its illustrations may stay centred inside their figure)
370
+ const narrow = new Set();
371
+ let column = 0;
372
+ for (const facts of states) {
373
+ column = Math.max(column, facts.column ?? 0);
374
+ for (const x of facts.narrow ?? []) narrow.add(x);
375
+ }
376
+ if (narrow.size > 0) bad(`${narrow.size} block(s) narrower than the ${column}px document column: ${[...narrow].slice(0, 4).join(', ')}; the executive view takes the full width of the column`);
377
+
378
+ // the rubric's labels and questions are never printed on the page
379
+ const printed = new Map();
380
+ for (const facts of states) {
381
+ const text = String(facts.text ?? '').toLowerCase();
382
+ for (const [kind, names] of [['label', EXEC_RUBRIC_LABELS], ['question', EXEC_RUBRIC_QUESTIONS]]) for (const x of names) if (text.includes(x.toLowerCase())) printed.set(x, kind);
251
383
  }
252
- // the writing limits, on the text a reader sees
384
+ for (const [x, kind] of printed) bad(`prints the rubric's ${kind} "${x}"; the rubric gives the ideas and the language, not the content (docs/06_executive-view.md)`);
385
+
386
+ // the writing limits, on the text a reader sees, across every sub-tab
387
+ const fields = new Map();
388
+ for (const facts of states) for (const f of facts.fields ?? []) fields.set(`${f.path}|${f.text}`, f);
253
389
  const unreadable = [];
254
- for (const f of facts.fields) {
390
+ for (const f of fields.values()) {
255
391
  if (!f.readable) {
256
392
  unreadable.push(f.path);
257
393
  continue;
258
394
  }
259
395
  const sentences = sentencesOf(f.text);
260
- for (const s of sentences) {
261
- const n = wordsIn(s);
396
+ for (const sentence of sentences) {
397
+ const n = wordsIn(sentence);
262
398
  if (n > EXEC_LIMITS.maxWords) bad(`field "${f.path}": a sentence of ${n} words; the limit is ${EXEC_LIMITS.maxWords}`);
263
399
  }
264
400
  const max = execSentenceLimit(f.path);
265
401
  if (max !== undefined && sentences.length > max) bad(`field "${f.path}": ${sentences.length} sentences; the limit is ${max}`);
402
+ const min = execMinWords(f.path);
403
+ if (min !== undefined && wordsIn(f.text) < min) bad(`field "${f.path}": ${wordsIn(f.text)} words; a paragraph needs at least ${min}`);
266
404
  if (EXEC_LIMITS.equation.test(f.text)) bad(`field "${f.path}": holds an equation or a TeX marker`);
267
405
  if (EXEC_LIMITS.citations.some((re) => re.test(f.text))) bad(`field "${f.path}": holds an inline citation (sources are numbered notes)`);
406
+ for (const re of EXEC_LIMITS.promotional) {
407
+ const m = re.exec(f.text);
408
+ if (m) bad(`field "${f.path}": holds promotional or client-bound wording ("${m[0]}"); the executive view is general, never a client, never a pitch`);
409
+ }
268
410
  }
269
- if (unreadable.length > 0) bad(`${unreadable.length} field(s) are in the page and a reader cannot read them: ${unreadable.slice(0, 4).join(', ')}`);
270
- if (facts.notes !== facts.figures) bad(`${facts.notes} numbered source note(s) for ${facts.figures} figure(s)`);
411
+ if (unreadable.length > 0) bad(`${unreadable.length} field(s) are in the page and a reader cannot read them: ${[...new Set(unreadable)].slice(0, 4).join(', ')}`);
412
+
413
+ // the source notes: always there, one per source cited, each a title that links to an https url, with a note that cites no file
414
+ const cited = new Set(states.flatMap((x) => x.cites ?? []));
415
+ for (const facts of states) {
416
+ const notes = facts.notes ?? [];
417
+ if (notes.length !== cited.size) bad(`${notes.length} source note(s) for ${cited.size} source(s) cited`);
418
+ notes.forEach((n, i) => {
419
+ const at = `source note ${i + 1}`;
420
+ if (!n.href || !n.title) {
421
+ bad(`${at} has no link: a source is its title linked to an https:// url (docs/06_executive-view.md)`);
422
+ return;
423
+ }
424
+ if (!/^https:\/\/\S+$/.test(n.href)) bad(`${at} links to "${n.href}"; a source is an https:// link`);
425
+ for (const [what, text, max] of [['title', n.title, undefined], ['note', n.note, 1]]) {
426
+ if (!text) continue;
427
+ for (const m of execSourceTextProblems(text, max)) bad(`${at}: the ${what} ${m}`);
428
+ }
429
+ });
430
+ }
431
+
432
+ // the two onward links under the sources, worded as destinations
433
+ const links = first.links ?? [];
434
+ for (const kind of ['technical', 'app']) {
435
+ const got = links.find((x) => x.kind === kind);
436
+ if (!got) bad(`no onward link to ${kind === 'app' ? 'the App' : 'the technical view'} under the sources`);
437
+ else if (got.text !== EXEC_LINKS[kind][lang]) bad(`the onward link to ${kind === 'app' ? 'the App' : 'the technical view'} reads "${got.text}", expected "${EXEC_LINKS[kind][lang]}"`);
438
+ else if (kind === 'app' && !/\/app\/?(\?|#|$)/.test(got.href ?? '')) bad(`the onward link to the App goes to "${got.href}", not to the App route`);
439
+ }
440
+
271
441
  // the three illustrations, in their places
272
- const names = facts.illustrations.map((x) => x.name);
273
- if (names.join() !== EXEC_ILLUSTRATIONS.map((x) => x.name).join()) bad(`the illustrations are [${names.join(', ')}], expected [initiative, problem, solution] in that order`);
274
- for (const ill of facts.illustrations) {
442
+ const seen = new Map();
443
+ for (const facts of states) for (const ill of facts.illustrations ?? []) if (!seen.has(ill.name)) seen.set(ill.name, ill);
444
+ const names = [...seen.keys()];
445
+ const expected = EXEC_ILLUSTRATIONS.map((x) => x.name);
446
+ if (names.slice().sort().join() !== expected.slice().sort().join()) bad(`the illustrations are [${names.join(', ')}], expected [${expected.join(', ')}]`);
447
+ for (const ill of seen.values()) {
275
448
  const want = EXEC_ILLUSTRATIONS.find((x) => x.name === ill.name);
276
449
  const name = `illustration "${ill.name}"`;
277
450
  if (want && ill.section !== want.section) bad(`${name} sits in section ${ill.section}, it belongs in section ${want.section}`);
@@ -369,6 +542,7 @@ export function docFloorFailures({ route, lang, states, expectedTopics }) {
369
542
  const figures = new Set();
370
543
  const equations = new Set();
371
544
  const execMessages = new Set();
545
+ const execStates = [];
372
546
  for (const st of states) {
373
547
  for (const t of st.topics) topics.set(t.key, t.label);
374
548
  const ordinal = new Map();
@@ -386,11 +560,12 @@ export function docFloorFailures({ route, lang, states, expectedTopics }) {
386
560
  bad('', `${NAME} (${L}): the ExecutiveView sits in the "${st.view ?? 'none'}" view; it belongs to the executive view`);
387
561
  }
388
562
  if (route === '/introduction' && st.view === 'executive') {
389
- for (const m of executiveFailures(st.executive, lang)) execMessages.add(m);
563
+ execStates.push(st.executive);
390
564
  if (st.viewEquations > 0) bad(st.topics[0]?.label ?? '', `${NAME}, executive view (${L}): ${st.viewEquations} equation(s); the executive view holds no equation`);
391
565
  if (st.viewGlossaries > 0) bad(st.topics[0]?.label ?? '', `${NAME}, executive view (${L}): ${st.viewGlossaries} symbol glossary(ies); the executive view holds none`);
392
566
  }
393
567
  }
568
+ if (route === '/introduction' && execStates.length > 0) for (const m of executiveFailures(execStates, lang)) execMessages.add(m);
394
569
  for (const m of execMessages) bad('', m);
395
570
  const seen = [...sections.values()].map(mergeSections);
396
571
  const tname = (label) => (label ? `topic "${label}"` : 'the page');
@@ -398,6 +573,7 @@ export function docFloorFailures({ route, lang, states, expectedTopics }) {
398
573
  if (expectedTopics > topics.size) bad('', `${NAME} (${L}): the walk visited ${topics.size} of ${expectedTopics} topics; the floors of an unvisited topic were never checked`);
399
574
 
400
575
  // ---- the lede
576
+ if (first.ledeWidth > 0 && first.ledeWidth < first.columnWidth - 2) bad('', `${NAME} (${L}): the lede is ${first.ledeWidth}px wide in a ${first.columnWidth}px document column; the lede takes the full width of the column`);
401
577
  if (!waived.has('lede-words') && first.ledeWords < LEDE_WORDS) bad('', `${NAME} (${L}): the lede holds ${first.ledeWords} word(s), the floor is ${LEDE_WORDS}`);
402
578
 
403
579
  // ---- the Introduction's views: the executive one first
package/gate/inpage.mjs CHANGED
@@ -503,6 +503,9 @@ export function installLib() {
503
503
  docBound = page;
504
504
  const seen = (el) => readable(el, page, 4, 4);
505
505
  const lede = page.querySelector('.lede');
506
+ // the document column: the page body without its padding; the lede and the executive view take all of it (Felipe, 2026-10-13)
507
+ const pageStyle = getComputedStyle(page);
508
+ const columnWidth = Math.round(page.clientWidth - parseFloat(pageStyle.paddingLeft) - parseFloat(pageStyle.paddingRight));
506
509
  const panels = [...page.querySelectorAll('[role="tabpanel"]')].filter(visible);
507
510
  // a tab list inside a section belongs to that section: its panels are not topics, and it does not make its topic a branch
508
511
  const inSection = (el) => Boolean(el.closest('[data-doc-section]'));
@@ -614,7 +617,7 @@ export function installLib() {
614
617
  };
615
618
  });
616
619
  const selected = outerTabs.find((t) => t.getAttribute('aria-selected') === 'true');
617
- // The executive view (shell 0.12.0): what the ExecutiveView shows, for executiveFailures of gate/areas.mjs.
620
+ // The executive view (shell 0.13.0): what the ExecutiveView shows in this state (one sub-tab = one section), for executiveFailures of gate/areas.mjs.
618
621
  const execRoots = [...page.querySelectorAll('[data-executive-view]')].filter(visible);
619
622
  const executive = (() => {
620
623
  if (!execRoots.length) return { count: 0 };
@@ -660,17 +663,31 @@ export function installLib() {
660
663
  return info;
661
664
  });
662
665
  const sections = [...root.querySelectorAll('[data-exec-section]')].map((sec) => {
663
- const pending = sec.querySelector('[data-exec-pending]');
666
+ const key = sec.querySelector('[data-exec-key-message]');
664
667
  return {
665
668
  n: Number(sec.getAttribute('data-exec-section')),
666
- title: textOf(sec.querySelector('h2') || sec),
667
- question: textOf(sec.querySelector('[data-exec-question]') || sec),
668
- pending: pending && seen(pending) ? 1 : 0,
669
- pendingText: pending ? textOf(pending) : '',
670
- pendingState: pending ? pending.getAttribute('data-state') : null,
671
- rows: [...sec.querySelectorAll('[data-exec-c]')].filter(seen).length,
669
+ title: (sec.getAttribute('aria-label') || '').trim(),
670
+ // the key message line comes first in the section, and a reader can read it
671
+ keyFirst: Boolean(key) && sec.firstElementChild === key,
672
+ keyMessage: key && seen(key) ? textOf(key) : '',
672
673
  };
673
674
  });
675
+ // the rows each part of the view shows in this state (the floors), counted once per kind
676
+ const items = {};
677
+ for (const el of root.querySelectorAll('[data-exec-item]')) if (seen(el)) items[el.getAttribute('data-exec-item')] = (items[el.getAttribute('data-exec-item')] ?? 0) + 1;
678
+ const landingEl = root.querySelector('[data-exec-landing]');
679
+ const landing = {
680
+ present: Boolean(landingEl && seen(landingEl)),
681
+ title: landingEl ? textOf(landingEl.querySelector('[data-exec-field="title"]') || landingEl).slice(0, 80) : '',
682
+ statement: landingEl ? textOf(landingEl.querySelector('[data-exec-field="key_statement"]') || landingEl).slice(0, 80) : '',
683
+ };
684
+ // the 4 C's a reader sees in the proposal: the name, the definition and what each means for the proposal
685
+ const cs = [...root.querySelectorAll('[data-exec-c]')]
686
+ .filter(seen)
687
+ .map((li) => ({ name: textOf(li.querySelector('[data-exec-cname]') || li), def: textOf(li.querySelector('[data-exec-cdef]') || li), applies: textOf(li.querySelector('[data-exec-field$=".applies"]') || li.querySelector('span') || li) }));
688
+ const links = [...root.querySelectorAll('[data-exec-links] a')].map((a) => ({ kind: a.getAttribute('data-exec-link'), text: textOf(a), href: a.getAttribute('href') }));
689
+ // the sub-tabs of the view: their labels (all of them, shown or not), and the one selected
690
+ const tabEls = [...root.querySelectorAll('[data-exec-tabs] [role="tab"]')];
674
691
  const panelOpen = outerPanel ? outerPanel.contains(root) : true;
675
692
  const execOpen = selected && (selected.getAttribute('data-tab') || tabLabel(selected)) === 'executive';
676
693
  const area = execOpen && outerPanel ? outerPanel : null;
@@ -691,15 +708,40 @@ export function installLib() {
691
708
  docSectionTitles: docSections.map((e) => textOf(e.querySelector('h2') || e).slice(0, 40)),
692
709
  strangers,
693
710
  sections,
711
+ landing,
712
+ items,
713
+ cs,
714
+ links,
694
715
  fields: [...root.querySelectorAll('[data-exec-field]')].map((e) => ({ path: e.getAttribute('data-exec-field'), text: textOf(e), readable: seen(e) })),
695
- notes: root.querySelectorAll('[data-exec-notes] > li').length,
696
- figures: root.querySelectorAll('.caos-exec-cite').length,
716
+ // the blocks of this state narrower than the document column (an illustration may stay centred inside its figure)
717
+ narrow: [...root.querySelectorAll('.subtabs, .caos-exec-notes-wrap, [data-exec-section], [data-exec-section] > *')]
718
+ .filter((e) => visible(e))
719
+ .map((e) => [`${e.tagName.toLowerCase()}${e.className && typeof e.className === 'string' ? '.' + e.className.split(' ')[0] : ''}`, Math.round(e.getBoundingClientRect().width)])
720
+ .filter(([, w]) => w < columnWidth - 2)
721
+ .map(([name, w]) => `${name} ${w}px`),
722
+ column: columnWidth,
723
+ // everything the view prints in this state, for the check that no rubric label or question is on the page
724
+ text: textOf(root),
725
+ tabs: tabEls.map(tabLabel),
726
+ activeTab: tabEls.findIndex((t) => t.getAttribute('aria-selected') === 'true'),
727
+ // each source note: the link it holds (its target and its text) and the text beside the link
728
+ notes: [...root.querySelectorAll('[data-exec-notes] > li')].map((li) => {
729
+ const a = li.querySelector('a');
730
+ const link = a && seen(a) ? textOf(a) : '';
731
+ const note = li.querySelector('[data-exec-source-note]');
732
+ const rest = note ? textOf(note) : textOf(li).replace(link, '').trim();
733
+ return { href: a ? a.getAttribute('href') : null, title: link, note: rest };
734
+ }),
735
+ // the figures this state shows: the notes they point at (the same figure seen in two states counts once)
736
+ cites: [...root.querySelectorAll('.caos-exec-cite a')].map((a) => a.getAttribute('href')),
697
737
  illustrations,
698
738
  };
699
739
  })();
700
740
  const inView = (sel) => (outerPanel ? [...outerPanel.querySelectorAll(sel)] : [...page.querySelectorAll(sel)]).filter(seen);
701
741
  return {
702
742
  ledeWords: lede && visible(lede) ? wordsOf(lede).words : 0,
743
+ ledeWidth: lede && visible(lede) ? Math.round(lede.getBoundingClientRect().width) : 0,
744
+ columnWidth,
703
745
  waiver: page.getAttribute('data-floor-waiver'),
704
746
  topics,
705
747
  views: outerTabs.map((t) => t.getAttribute('data-tab') || tabLabel(t)),
package/gate/run.mjs CHANGED
@@ -37,7 +37,7 @@ export const DEFAULTS = {
37
37
  sizes: '390x844,768x1024,1280x800,1600x900,2560x1440',
38
38
  themes: 'light,dark',
39
39
  langs: 'en,es',
40
- workbench: '/',
40
+ workbench: '/app',
41
41
  idleMs: 3000,
42
42
  settleMs: 15000,
43
43
  textFloor: 400,
@@ -84,6 +84,7 @@ export async function runGate(o) {
84
84
  const sizes = String(opt.sizes).split(',').map((s) => s.trim().split('x').map(Number));
85
85
  const themes = String(opt.themes).split(',').map((s) => s.trim());
86
86
  const langs = String(opt.langs).split(',').map((s) => s.trim());
87
+ const workbenchRoute = String(opt.workbench).split(',')[0].trim() || '/app';
87
88
  const workbenchRoutes = new Set(String(opt.workbench).split(',').map((s) => s.trim()).filter(Boolean));
88
89
  const primarySize = sizes.find(([w]) => w >= opt.viewportMinWidth) ?? sizes[0];
89
90
  const shotsDir = join(opt.out, 'shots');
@@ -240,15 +241,39 @@ export async function runGate(o) {
240
241
  await ctx.close();
241
242
  return finish();
242
243
  }
243
- if (why) fail('G7', { route: '/' }, `the App route never settled: ${why}`);
244
+ if (why) fail('G7', { route: '/' }, `the landing route never settled: ${why}`);
244
245
  if (!routes) {
245
246
  const hrefs = await page.$$eval('header nav a[href]', (as) => as.map((a) => a.href));
246
- routes = [...new Set(['/', ...hrefs.map(routeOf).filter(Boolean)])];
247
+ routes = [...new Set(hrefs.map(routeOf).filter(Boolean))];
247
248
  }
248
249
  sink.splice(0);
249
250
  await ctx.close();
250
251
  }
251
252
 
253
+ // ---- The landing (shell 0.13.0): the root opens the Introduction on its executive view; an old App link with a query string reaches the App.
254
+ {
255
+ const ctx = await openContext(primarySize, themes[0], langs[0]);
256
+ const { page, sink } = await newPage(ctx);
257
+ await page.goto(urlFor('/'), { waitUntil: 'load', timeout: 30000 }).catch(() => undefined);
258
+ await settle(page);
259
+ const at = { route: '/', mode: 'landing' };
260
+ if (routeOf(page.url()) !== '/introduction') {
261
+ fail('G4', at, `the root opened ${routeOf(page.url()) ?? page.url()}, not the Introduction; every product lands on the Introduction (Landing of the shell on the root path)`);
262
+ } else {
263
+ const landed = await page.evaluate(() => window.__caosGate.docPage());
264
+ if (!landed || landed.view !== 'executive' || !landed.executive || landed.executive.count !== 1) fail('G4', at, `the root opened the Introduction on the "${landed?.view ?? 'none'}" view; the landing opens its executive view`);
265
+ }
266
+ const old = await page.goto(`${urlFor('/')}?gate=landing#frag`, { waitUntil: 'load', timeout: 30000 }).catch(() => undefined);
267
+ await settle(page);
268
+ const u = new URL(page.url());
269
+ if (routeOf(page.url()) !== workbenchRoute || u.searchParams.get('gate') !== 'landing' || u.hash !== '#frag') {
270
+ fail('G4', { route: '/', mode: 'landing' }, `an old App link /?gate=landing#frag reached ${routeOf(page.url())}${u.search}${u.hash}; it must reach ${workbenchRoute} with its query string and hash kept (Landing keeps the query string and the hash)`);
271
+ }
272
+ void old;
273
+ sink.splice(0);
274
+ await ctx.close();
275
+ }
276
+
252
277
  // ---- The walk: every mode, every route, every tab, every case (G2 to G9 and the ADR measures).
253
278
  for (const size of sizes) {
254
279
  for (const theme of themes) {
@@ -281,14 +306,14 @@ export async function runGate(o) {
281
306
  }
282
307
  if (id.theme !== theme) fail('G2', at, `asked for the ${theme} theme through ${THEME_STORAGE_KEY}; the document has data-theme=${id.theme}`);
283
308
  if (!String(id.lang ?? '').startsWith(lang)) fail('G2', at, `asked for ${lang} through ${LANG_STORAGE_KEY}; <html lang> is ${id.lang}`);
284
- if (route !== '/' && id.activeHref && routeOf(id.activeHref) !== route) fail('G4', at, `loaded directly, the page shows route ${routeOf(id.activeHref)}`);
309
+ if (id.activeHref && routeOf(id.activeHref) !== route) fail('G4', at, `loaded directly, the page shows route ${routeOf(id.activeHref)}`);
285
310
 
286
311
  // ADR-0017 2.1: the documentation floors are measured once per language, at the primary size and first theme
287
312
  const docFloors = size === primarySize && theme === themes[0] && DOC_ROUTES.includes(route);
288
313
  const walkState = { tested: new Set(), keyedViews: 0, controls: 0, tablists: 0, doc: docFloors ? [] : null };
289
314
  await processState(page, sink, { ...at, trail: '' }, { route, isWorkbench, primary, idle: idleHere, first: true, size, w, h, theme, lang, walkState, contrast });
290
315
 
291
- if (primary && route !== '/') {
316
+ if (primary) {
292
317
  // G4: the trailing-slash form a static host serves for a materialised route.
293
318
  const slash = `${urlFor(route)}/`;
294
319
  const r2 = await page.goto(slash, { waitUntil: 'load', timeout: 30000 }).catch((e) => e);
@@ -324,8 +349,8 @@ export async function runGate(o) {
324
349
 
325
350
  if (primary) {
326
351
  // ADR-0071 rule 9 / G5: every header route reached by a real pointer click from the App route.
327
- for (const route of routes.filter((r) => r !== '/')) {
328
- await page.goto(urlFor('/'), { waitUntil: 'load', timeout: 30000 }).catch(() => undefined);
352
+ for (const route of routes.filter((r) => r !== workbenchRoute)) {
353
+ await page.goto(urlFor(workbenchRoute), { waitUntil: 'load', timeout: 30000 }).catch(() => undefined);
329
354
  await settle(page);
330
355
  const link = page.locator('header nav a[href]');
331
356
  const hrefs = await link.evaluateAll((as) => as.map((a) => a.href));
@@ -751,5 +776,5 @@ export function captureIndex(report, captures) {
751
776
  }
752
777
 
753
778
  function slug(route) {
754
- return route === '/' ? 'app' : route.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '');
779
+ return route === '/' ? 'landing' : route.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '');
755
780
  }