@fasl-work/caos-app-shell 0.15.0 → 0.17.0

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.
package/gate/run.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  // G4 deep links every route loaded directly (and with a trailing slash), 200 and rendered; artifacts
9
9
  // are JSON, not the HTML fallback; a missing asset answers 404; doc text clears a floor
10
10
  // G5 scroll and reach boxes inside the viewport, the rail and their scrollers; tall views scroll; nothing is
11
- // cut; truncated text carries its full text; every control reachable by the pointer
11
+ // cut; truncated text carries its full text; a select shows its whole selected text (0.17.0); every control reachable by the pointer
12
12
  // G6 painted area drawing surfaces have painted pixels, fill their stage, and cover at least half the
13
13
  // viewport on the App route, measured on the screenshot, never on a host box
14
14
  // G7 walk everything every route, tab, sub-tab and case, at five sizes, both themes, both languages,
@@ -29,6 +29,8 @@
29
29
  // G18 Focus mode the App's Focus control opens the case's open group in the full viewport and returns to the same case and
30
30
  // group (by the exit control and by Escape); no page scroll, the rail without scroll, one tab row; no stage
31
31
  // control under the Focus chrome (label, HUD, actions); no Focus control inside the Focus frame
32
+ // on a surface App (--workbench '', --app-route; 0.16.0) the same for the views of its tab row: another view or
33
+ // sub-view in Focus or after the return, no ?focus=1 and view in the URL, no view that offers the control
32
34
  // G19 values whole a short value (number, percentage, amount, unit) sits on one line in every state
33
35
  // plus the ADR-0071 and ADR-0017 measures (viewport, one-row tabs, rail without scroll, centred prose, captions,
34
36
  // references).
@@ -45,6 +47,7 @@ export const DEFAULTS = {
45
47
  themes: 'light,dark',
46
48
  langs: 'en,es',
47
49
  workbench: '/app',
50
+ appRoute: '/app',
48
51
  idleMs: 3000,
49
52
  settleMs: 15000,
50
53
  textFloor: 400,
@@ -93,6 +96,8 @@ export async function runGate(o) {
93
96
  const themes = String(opt.themes).split(',').map((s) => s.trim());
94
97
  const langs = String(opt.langs).split(',').map((s) => s.trim());
95
98
  const workbenchRoute = String(opt.workbench).split(',')[0].trim() || '/app';
99
+ // the App route of a product whose App is a surface (`--workbench ''`): G18 measures its Focus mode (shell 0.16.0)
100
+ const appRoute = String(opt.appRoute ?? '').trim() || '/app';
96
101
  const workbenchRoutes = new Set(String(opt.workbench).split(',').map((s) => s.trim()).filter(Boolean));
97
102
  const primarySize = sizes.find(([w]) => w >= opt.viewportMinWidth) ?? sizes[0];
98
103
  const shotsDir = join(opt.out, 'shots');
@@ -103,7 +108,7 @@ export async function runGate(o) {
103
108
  let server = null;
104
109
  let base = opt.url;
105
110
  if (opt.serve) {
106
- server = await servePages(opt.serve, opt.basePath ?? '/');
111
+ server = await servePages(opt.serve, opt.basePath ?? '/', 0, { spaFallback: Boolean(opt.spaFallback) });
107
112
  base = server.url;
108
113
  }
109
114
  base = base.endsWith('/') ? base : `${base}/`;
@@ -259,6 +264,20 @@ export async function runGate(o) {
259
264
  await ctx.close();
260
265
  }
261
266
 
267
+ // ---- G18 on a surface App: under --require-focus the App route must be among the measured routes, or nothing would be measured
268
+ if (opt.requireFocus && workbenchRoutes.size === 0 && !routes.includes(appRoute)) {
269
+ fail('G18', { route: appRoute, mode: 'routes' }, `the App route ${appRoute} is not among the measured routes (${routes.join(', ') || 'none'}): with --workbench '' and --require-focus the gate measures the App as a surface and must walk it; add it to --routes or name it with --app-route`);
270
+ }
271
+
272
+ // ---- G18 on a workbench App (shell 0.17.0): under --require-focus every workbench route must be among the measured routes, or no Focus would be measured
273
+ if (opt.requireFocus) {
274
+ for (const wr of workbenchRoutes) {
275
+ if (!routes.includes(wr)) {
276
+ fail('G18', { route: wr, mode: 'routes' }, `the workbench route ${wr} is not among the measured routes (${routes.join(', ') || 'none'}): with --require-focus the gate must walk the App to measure its Focus mode; add it to --routes or drop it from --workbench`);
277
+ }
278
+ }
279
+ }
280
+
262
281
  // ---- 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.
263
282
  {
264
283
  const ctx = await openContext(primarySize, themes[0], langs[0]);
@@ -344,6 +363,7 @@ export async function runGate(o) {
344
363
  if (walkState.doc) docResult(at, lang, walkState.doc, Math.max(1, leaves));
345
364
 
346
365
  if (isWorkbench) await walkCases(page, sink, at, { route, isWorkbench, primary, interact: idleHere, size, w, h, theme, lang, walkState, contrast });
366
+ else if (workbenchRoutes.size === 0 && route === appRoute) await surfaceFocusWalk(page, sink, at, { route, isWorkbench, primary, interact: idleHere, size, w, h, theme, lang, walkState, contrast });
347
367
 
348
368
  if (primary && isWorkbench) {
349
369
  const f = await page.evaluate(() => window.__caosGate.facts());
@@ -449,6 +469,7 @@ export async function runGate(o) {
449
469
  if (f.rail.outsideCount) fail('G5', where, `${f.rail.outsideCount} rail element(s) outside the rail: ${f.rail.outside.join('; ')}`);
450
470
  }
451
471
  if (f.truncatedCount) fail('G5', where, `${f.truncatedCount} truncated text(s) without the full text in a title: ${f.truncated.join('; ')}`);
472
+ if (f.cutSelectCount) fail('G5', where, `${f.cutSelectCount} select(s) cut the selected text (a case name cut in its box, or a display that names another case than the one the select holds, or a plain select with a text wider than its room): ${f.cutSelects.join('; ')}`);
452
473
  for (const c of f.clipped) fail('G5', where, c);
453
474
  if (f.offCentre !== null && f.offCentre > 2) fail('ADR-0017.1', where, `the prose page is off-centre by ${f.offCentre.toFixed(1)}px`);
454
475
  if (f.referenceLists) fail('ADR-0017.4', where, `${f.referenceLists} bibliography outside the end-of-page references block (a .reference-list); give each DocSection its refs and let the page list them once`);
@@ -847,16 +868,18 @@ export async function runGate(o) {
847
868
  await subs.nth(sn - 1).click({ timeout: 4000 }).catch(() => undefined);
848
869
  await settle(page);
849
870
  }
850
- await focusVisitOne(page, sink, where, s, caseId, index + k, total, enter);
871
+ await focusVisitOne(page, sink, where, s, { kind: 'case', id: caseId, total }, index + k, enter);
851
872
  }
852
873
  await tabs.nth(0).click({ timeout: 4000 }).catch(() => undefined);
853
874
  await settle(page);
854
875
  }
855
876
 
856
- async function focusVisitOne(page, sink, where, s, caseId, index, total, enter) {
857
- const at = { ...where, trail: `case ${caseId} > Focus` };
877
+ async function focusVisitOne(page, sink, where, s, subject, index, enter) {
878
+ const isView = subject.kind === 'view';
879
+ const caseId = subject.id;
880
+ const at = { ...where, trail: `${subject.kind} ${subject.id} > Focus` };
858
881
  const facts = () => page.evaluate(() => window.__caosGate.focusFacts());
859
- const label = `Focus mode of case ${caseId}`;
882
+ const label = `Focus mode of ${subject.kind} ${subject.id}`;
860
883
  const before = await facts();
861
884
  try {
862
885
  await enter.evaluate((el) => window.__caosGate.bringEl(el)).catch(() => undefined);
@@ -872,11 +895,18 @@ export async function runGate(o) {
872
895
  fail('G18', at, `${label}: the Focus control did not open Focus mode`);
873
896
  return;
874
897
  }
875
- if (f.caseId !== caseId) fail('G18', at, `${label} shows case ${f.caseId}`);
876
- if (f.group !== before.group) fail('G18', at, `${label} shows group ${f.group}, the App had ${before.group}`);
898
+ if (!isView && f.caseId !== caseId) fail('G18', at, `${label} shows case ${f.caseId}`);
899
+ if (f.group !== before.group) fail('G18', at, `${label} shows ${isView ? 'view' : 'group'} ${f.group}, the App had ${before.group}`);
877
900
  if (f.subview !== before.subview) fail('G18', at, `${label} shows sub-view ${f.subview}, the App had ${before.subview}`);
878
901
  const q = new URLSearchParams(f.search);
879
- if (q.get('focus') !== '1' || (total > 1 && q.get('case') !== caseId)) fail('G18', at, `${label}: the URL (${f.search || 'no query'}) does not hold ?focus=1 and the case`);
902
+ // the parameter the App holds Focus in (`focus`, or the product's own `param`), named by the Focus frame
903
+ const fp = f.focusParam || 'focus';
904
+ if (isView) {
905
+ // the view the App held in its URL stays there (a surface that keeps its view out of the URL has none to keep)
906
+ const param = before.viewParam || 'view';
907
+ const held = new URLSearchParams(before.search).get(param);
908
+ if (q.get(fp) !== '1' || (held !== null && q.get(param) !== held)) fail('G18', at, `${label}: the URL (${f.search || 'no query'}) does not hold ?${fp}=1 and the view (?${param}=)`);
909
+ } else if (q.get(fp) !== '1' || (subject.total > 1 && q.get('case') !== caseId)) fail('G18', at, `${label}: the URL (${f.search || 'no query'}) does not hold ?${fp}=1 and the case`);
880
910
  if (f.docW > f.vw + 1 || f.docH > f.vh + 1) fail('G18', at, `${label}: the page scrolls (the document is ${f.docW}x${f.docH}px in a ${f.vw}x${f.vh}px viewport); Focus mode is the viewport`);
881
911
  if (f.nestedMain) fail('G18', at, `${label}: the page holds a main inside a main (the Focus frame must not be a second landmark inside the App's main)`);
882
912
  // the keyboard stays in the Focus frame: the App behind it is out of the tab order
@@ -893,6 +923,7 @@ export async function runGate(o) {
893
923
  if (big && f.share < opt.instrumentMin) fail('G18', at, `${label}: the stage covers ${(100 * f.share).toFixed(1)}% of the viewport (floor ${(100 * opt.instrumentMin).toFixed(0)}%)`);
894
924
  if (f.exitInside === false) fail('G18', at, `${label}: the exit control is outside the viewport`);
895
925
  for (const c of f.covered) fail('G18', at, `${label}: ${c.chrome} covers the control "${c.control}" (the stage must clear the Focus chrome)`);
926
+ if (f.cutSelects.count) fail('G5', at, `${label}: ${f.cutSelects.count} select(s) cut the selected text (a case name cut in its box, or a display that names another case than the one the select holds, or a plain select with a text wider than its room): ${f.cutSelects.list.join('; ')}`);
896
927
  if (f.enterInside > 0) fail('G18', at, `${label}: the Focus control shows inside Focus mode (it opens the mode the reader is already in)`);
897
928
  for (const m of await page.evaluate(() => window.__caosGate.drawingText())) fail('G10', at, m);
898
929
  for (const m of await page.evaluate(() => window.__caosGate.brokenValues())) fail('G19', at, m);
@@ -917,18 +948,103 @@ export async function runGate(o) {
917
948
  const g = await facts();
918
949
  if (g.focus) {
919
950
  fail('G18', at, `${label}: ${byButton && left ? 'the exit control' : 'Escape'} does not return to the App`);
920
- await page.goto(`${urlFor(s.route)}?case=${encodeURIComponent(caseId)}`, { waitUntil: 'load', timeout: 30000 }).catch(() => undefined);
951
+ await page.goto(`${urlFor(s.route)}?${isView ? (before.viewParam || 'view') : 'case'}=${encodeURIComponent(caseId)}`, { waitUntil: 'load', timeout: 30000 }).catch(() => undefined);
921
952
  await settle(page);
922
953
  } else {
923
954
  if (!g.app) fail('G18', at, `${label}: back from Focus mode there is no App on the page`);
924
- if (g.caseId !== caseId) fail('G18', at, `${label}: back in the App on case ${g.caseId}, it was left on case ${caseId}`);
925
- if (g.group !== before.group) fail('G18', at, `${label}: back in the App on group ${g.group}, it was left on group ${before.group}`);
955
+ if (!isView && g.caseId !== caseId) fail('G18', at, `${label}: back in the App on case ${g.caseId}, it was left on case ${caseId}`);
956
+ if (g.group !== before.group) fail('G18', at, `${label}: back in the App on ${isView ? 'view' : 'group'} ${g.group}, it was left on ${isView ? 'view' : 'group'} ${before.group}`);
926
957
  if (g.subview !== before.subview) fail('G18', at, `${label}: back in the App on sub-view ${g.subview}, it was left on sub-view ${before.subview}`);
927
- if (new URLSearchParams(g.search).has('focus')) fail('G18', at, `${label}: the URL still holds ?focus after the return`);
958
+ if (new URLSearchParams(g.search).has(fp)) fail('G18', at, `${label}: the URL still holds ?${fp} after the return`);
928
959
  }
929
960
  drain(sink, at);
930
961
  }
931
962
 
963
+ /**
964
+ * G18 on an App that is a surface (`--workbench ''`, the App route `--app-route`, shell 0.16.0): every view of the surface's tab row is
965
+ * opened and its tab row measured (the row and the head must not move between views: the Focus control must add no row, ADR-0071);
966
+ * each view that offers the Focus control is entered from its last sub-view (the first view is where the walk leaves the App, so a
967
+ * Focus that fell back to it would go unseen) and, in the primary mode, left both by the exit button and by Escape; outside the primary
968
+ * mode, the last focusable view only. The same failures as for a workbench apply, with the view in place of the case and the group.
969
+ */
970
+ async function surfaceFocusWalk(page, sink, at, s) {
971
+ const rowTabs = page.locator('[data-surface] > .caos-surface-body > .tabs > [role="tablist"] > [role="tab"]');
972
+ const n = await rowTabs.count();
973
+ const focusable = [];
974
+ const rows = [];
975
+ // the tab row and the head, from the surface's own top (the document may have scrolled)
976
+ const geometry = () =>
977
+ page.evaluate(() => {
978
+ const surface = document.querySelector('[data-surface]');
979
+ const list = document.querySelector('[data-surface] > .caos-surface-body > .tabs > [role="tablist"]');
980
+ if (!surface || !list) return null;
981
+ const top = surface.getBoundingClientRect().top;
982
+ const r = list.getBoundingClientRect();
983
+ const head = document.querySelector('[data-surface] > .caos-surface-head');
984
+ const round = (x) => Math.round(x * 10) / 10;
985
+ // the height the tabs themselves need (their lowest edge, the row's bottom padding and rule): anything above it is height something
986
+ // else gave the row, such as a taller Focus control in the slot beside it
987
+ const cs = getComputedStyle(list);
988
+ // the tabs stretch to the row, so they are measured at their natural height (aligned to the start for this one reading)
989
+ const before = list.style.alignItems;
990
+ list.style.alignItems = 'flex-start';
991
+ const lowest = Math.max(0, ...[...list.querySelectorAll('[role="tab"]')].map((b) => b.getBoundingClientRect().bottom));
992
+ list.style.alignItems = before;
993
+ const need = lowest - r.top + parseFloat(cs.paddingBottom) + parseFloat(cs.borderBottomWidth);
994
+ return { top: round(r.top - top), height: round(r.height), head: head ? round(head.getBoundingClientRect().height) : 0, slack: round(r.height - need) };
995
+ });
996
+ for (let i = 0; i < n; i += 1) {
997
+ await rowTabs.nth(i).click({ timeout: 4000 }).catch(() => undefined);
998
+ await settle(page);
999
+ const shown = (await page.locator('[data-focus-enter]').filter({ visible: true }).count()) > 0;
1000
+ const facts = await page.evaluate(() => window.__caosGate.focusFacts());
1001
+ if (shown) focusable.push({ index: i, id: facts.group });
1002
+ rows.push({ id: facts.group, geo: await geometry() });
1003
+ }
1004
+ if (focusable.length > 0 && rows[0]?.geo) {
1005
+ for (const r of rows.slice(1)) {
1006
+ if (!r.geo) continue;
1007
+ const g0 = rows[0].geo;
1008
+ if (r.geo.top !== g0.top || r.geo.height !== g0.height || r.geo.head !== g0.head) {
1009
+ fail('G18', { ...at, trail: `view ${r.id}` }, `the surface's tab row or head moves between views (view ${rows[0].id}: row at ${g0.top}px, ${g0.height}px high, head ${g0.head}px; view ${r.id}: row at ${r.geo.top}px, ${r.geo.height}px high, head ${r.geo.head}px): a view must not add a row or shift the tab row (ADR-0071); the Focus control sits on the tab row with its space reserved and adds none, and a per-view head is the product's to keep steady`);
1010
+ break;
1011
+ }
1012
+ }
1013
+ }
1014
+ if (focusable.length > 0) {
1015
+ const tall = rows.find((r) => r.geo && r.geo.slack > 3);
1016
+ if (tall) fail('G18', { ...at, trail: `view ${tall.id}` }, `the surface's tab row is ${tall.geo.slack}px taller than its tabs need (view ${tall.id}, row ${tall.geo.height}px): something beside the tabs, such as a taller Focus control in the tab row's slot, adds height to the row on every view (ADR-0071)`);
1017
+ const empty = (await page.evaluate(() => window.__caosGate.focusFacts())).emptyHead;
1018
+ if (empty !== null) fail('G18', { ...at, trail: 'surface head' }, `the surface renders a head with no title, lede or actions (an empty head row ${empty}px high): a surface without them has no head, and the Focus control sits on the tab row, never in a head of its own (ADR-0071)`);
1019
+ }
1020
+ if (focusable.length === 0) {
1021
+ if (opt.requireFocus) {
1022
+ fail('G18', { ...at, trail: 'surface > Focus' }, `the surface App (${at.route}) offers no Focus control ([data-focus-enter]) on any of its ${n} view(s): ADR-0070 requires a visible control on every view with a stage that opens it in focus mode (set TabDef.focus on the view and focus on the SurfacePage)`);
1023
+ }
1024
+ if (n > 0) await rowTabs.nth(0).click({ timeout: 4000 }).catch(() => undefined);
1025
+ await settle(page);
1026
+ return;
1027
+ }
1028
+ const targets = s.primary ? focusable : focusable.slice(-1);
1029
+ for (const [k, v] of targets.entries()) {
1030
+ // the primary mode leaves each focusable view twice: by the exit button (even index), then by Escape (odd index)
1031
+ for (const way of s.primary ? [0, 1] : [0]) {
1032
+ await rowTabs.nth(v.index).click({ timeout: 4000 }).catch(() => undefined);
1033
+ await settle(page);
1034
+ const subs = page.locator('[data-surface] .subtablist [role="tab"]');
1035
+ const sn = await subs.count();
1036
+ if (sn > 1) {
1037
+ await subs.nth(sn - 1).click({ timeout: 4000 }).catch(() => undefined);
1038
+ await settle(page);
1039
+ }
1040
+ const enter = page.locator('[data-focus-enter]').filter({ visible: true }).first();
1041
+ await focusVisitOne(page, sink, at, s, { kind: 'view', id: v.id }, 2 * k + way, enter);
1042
+ }
1043
+ }
1044
+ await rowTabs.nth(0).click({ timeout: 4000 }).catch(() => undefined);
1045
+ await settle(page);
1046
+ }
1047
+
932
1048
  async function workbenchKey(page) {
933
1049
  return page.evaluate(() => document.querySelector('[data-case-workbench]')?.getAttribute('data-state-key') ?? null);
934
1050
  }
package/gate/serve.mjs CHANGED
@@ -3,6 +3,12 @@
3
3
  // index.html; `/name` falls back to `name.html`; anything else is 404 with the site's 404.html. There is no SPA
4
4
  // fallback, which is the point: a route that was not materialised, or an artifact missing from the build, fails here
5
5
  // exactly as it would on Pages (failure classes 2 and 22: deep links that 404, artifacts answered with HTML).
6
+ // With `spaFallback` (`--spa-fallback`, shell 0.16.0) it answers as nginx does for a single-page app on a VPS
7
+ // (`try_files $uri $uri/ /index.html`): an extensionless path that matches no file gets index.html with 200; a path with an
8
+ // extension that matches no file stays 404, so a missing artifact still fails. Edge paths, by design of a gate server and tested:
9
+ // a directory with no index.html answers the app shell (nginx: 301 then 403); a dot in a directory name reads as an extension, so
10
+ // `/route/with.dot/` and `/app/run.v2` stay 404 (nginx would serve the app); a MISSING dotfile such as `/.env` has no extension and
11
+ // answers the app shell (an existing file, a dotfile included, is served as it is); the query string is no part of the path (`/route?x=1.5` answers the app shell, `/missing.js?v=2` is 404).
6
12
 
7
13
  import { createServer } from 'node:http';
8
14
  import { existsSync, readFileSync, statSync } from 'node:fs';
@@ -37,12 +43,14 @@ const MIME = {
37
43
  * @param {string} dir the built site
38
44
  * @param {string} basePath the site's base path ('/' for a custom domain, '/Repo/' for a project page)
39
45
  * @param {number} port 0 for any free port (the gate), or a fixed one (a product's preview)
46
+ * @param {{ spaFallback?: boolean }} options `spaFallback`: answer an extensionless path that matches no file with index.html and 200
40
47
  * @returns {Promise<{ url: string, close: () => Promise<void> }>}
41
48
  */
42
- export function servePages(dir, basePath = '/', port = 0) {
49
+ export function servePages(dir, basePath = '/', port = 0, options = {}) {
43
50
  const root = resolve(dir);
44
51
  const base = basePath.endsWith('/') ? basePath : `${basePath}/`;
45
52
  const notFound = join(root, '404.html');
53
+ const rootIndex = join(root, 'index.html');
46
54
  const server = createServer((req, res) => {
47
55
  const send = (status, file, headers = {}) => {
48
56
  res.writeHead(status, { 'Content-Type': MIME[extname(file).toLowerCase()] ?? 'application/octet-stream', 'Cache-Control': 'no-store', ...headers });
@@ -57,6 +65,8 @@ export function servePages(dir, basePath = '/', port = 0) {
57
65
  };
58
66
  let path;
59
67
  let search = '';
68
+ // nginx `try_files $uri $uri/ /index.html`: a path with no extension that is no file answers the app shell
69
+ const miss = () => (options.spaFallback && !extname(path ?? '') && existsSync(rootIndex) ? send(200, rootIndex) : missing());
60
70
  try {
61
71
  const u = new URL(req.url ?? '/', 'http://x');
62
72
  path = decodeURIComponent(u.pathname);
@@ -77,7 +87,7 @@ export function servePages(dir, basePath = '/', port = 0) {
77
87
  if (st.isFile()) return send(200, file);
78
88
  if (st.isDirectory()) {
79
89
  const index = join(file, 'index.html');
80
- if (!existsSync(index)) return missing();
90
+ if (!existsSync(index)) return miss();
81
91
  if (!path.endsWith('/')) {
82
92
  // the query string survives the redirect, as a Pages host keeps it (an old link /app?case=B2 must reach its case)
83
93
  res.writeHead(301, { Location: `${path}/${search}` });
@@ -87,7 +97,7 @@ export function servePages(dir, basePath = '/', port = 0) {
87
97
  }
88
98
  }
89
99
  if (!path.endsWith('/') && existsSync(`${file}.html`)) return send(200, `${file}.html`);
90
- return missing();
100
+ return miss();
91
101
  });
92
102
  return new Promise((ok) => {
93
103
  server.listen(port, '127.0.0.1', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fasl-work/caos-app-shell",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "Shared web-app shell, workbench primitives, content primitives, design system and measured gate for the CAOS / Faena public apps (ADR-0016, ADR-0017, ADR-0071).",
5
5
  "license": "MIT",
6
6
  "author": "Felipe Santibáñez-Leal",
@@ -56,7 +56,7 @@
56
56
  "check:sdd": "python scripts/check_sdd.py ."
57
57
  },
58
58
  "peerDependencies": {
59
- "katex": "^0.16.0",
59
+ "katex": "^0.16.0 || ^0.17.0 || ^0.18.0 || ^0.19.0",
60
60
  "lucide-react": ">=0.400.0",
61
61
  "react": "^18 || ^19",
62
62
  "react-dom": "^18 || ^19",
@@ -67,11 +67,10 @@
67
67
  },
68
68
  "devDependencies": {
69
69
  "@playwright/test": "1.63.0",
70
- "@types/katex": "^0.16.7",
71
70
  "@types/react": "^19.0.0",
72
71
  "@types/react-dom": "^19.0.0",
73
72
  "esbuild": "0.28.1",
74
- "katex": "^0.16.11",
73
+ "katex": "0.19.0",
75
74
  "lucide-react": "1.43.0",
76
75
  "react": "^19.0.0",
77
76
  "react-dom": "^19.0.0",
@@ -155,6 +155,7 @@
155
155
  "caos-surface",
156
156
  "caos-surface-actions",
157
157
  "caos-surface-body",
158
+ "caos-surface-focus",
158
159
  "caos-surface-head",
159
160
  "caos-surface-heading",
160
161
  "caos-surface-lede",
@@ -197,8 +198,11 @@
197
198
  "cs-locked",
198
199
  "cs-modified",
199
200
  "cs-reset",
200
- "cs-select",
201
+ "cs-select-display",
202
+ "cs-select-native",
201
203
  "cs-select-row",
204
+ "cs-select-text",
205
+ "cs-select-wrap",
202
206
  "cs-source",
203
207
  "cs-source-label",
204
208
  "cs-source-tabs",
@@ -277,6 +281,8 @@
277
281
  "tablist",
278
282
  "tabpanel",
279
283
  "tabs",
284
+ "tabs-has-slot",
285
+ "tabs-slot",
280
286
  "th-refs",
281
287
  "th-refs-label",
282
288
  "tick-list",
@@ -292,6 +298,7 @@
292
298
  "accent",
293
299
  "active",
294
300
  "caos-col-text",
301
+ "caos-focus-norail",
295
302
  "compact",
296
303
  "contain",
297
304
  "error",
@@ -413,6 +420,7 @@
413
420
  "caos-focus-hud",
414
421
  "caos-focus-instrument",
415
422
  "caos-focus-label",
423
+ "caos-focus-norail",
416
424
  "caos-focus-rail",
417
425
  "caos-focus-rail-body",
418
426
  "caos-focus-shell",
@@ -462,6 +470,7 @@
462
470
  "caos-surface",
463
471
  "caos-surface-actions",
464
472
  "caos-surface-body",
473
+ "caos-surface-focus",
465
474
  "caos-surface-head",
466
475
  "caos-surface-heading",
467
476
  "caos-surface-lede",
@@ -506,8 +515,11 @@
506
515
  "cs-locked",
507
516
  "cs-modified",
508
517
  "cs-reset",
509
- "cs-select",
518
+ "cs-select-display",
519
+ "cs-select-native",
510
520
  "cs-select-row",
521
+ "cs-select-text",
522
+ "cs-select-wrap",
511
523
  "cs-source",
512
524
  "cs-source-label",
513
525
  "cs-source-tabs",
@@ -596,6 +608,8 @@
596
608
  "tablist",
597
609
  "tabpanel",
598
610
  "tabs",
611
+ "tabs-has-slot",
612
+ "tabs-slot",
599
613
  "th-refs",
600
614
  "th-refs-label",
601
615
  "tick-list",
package/styles.css CHANGED
@@ -507,11 +507,28 @@ svg.lucide { flex: none; }
507
507
  .cs-reset { border: none; background: transparent; color: var(--color-accent); font-size: 0.8rem; font-weight: 600; padding: 0.1rem 0.2rem; text-decoration: underline; text-underline-offset: 2px; }
508
508
  /* The compact one-of-N case picker (ADR-0071 rule 7): ten cases in four categories cost a 308px chip block, 21% of a
509
509
  2560x1440 viewport, and one row as a dropdown. */
510
- .cs-select { width: 100%; max-width: 460px; }
511
- /* the select and a control beside it (the workbench's Focus button): one row, the select shrinks, the control keeps its size */
510
+ /* The case select shows the selected name whole, at any length, in a display (as many lines as the longest name of the list needs, measured in the DOM by CaseSelector; no clamp, no ellipsis), the native select on top of it, transparent by colour (shell 0.17.0; BL-074). */
511
+ .cs-select-wrap { position: relative; width: 100%; max-width: 460px; min-width: 0; }
512
+ .cs-select-display { box-sizing: border-box; display: flex; align-items: center; position: relative; min-width: 0; background: var(--color-surface-2); color: var(--color-fg); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 0.35rem 1.7rem 0.35rem 0.5rem; font-size: 0.85rem; line-height: 1.25; }
513
+ .cs-select-text { display: block; max-width: 100%; min-width: 0; overflow-wrap: anywhere; }
514
+ .cs-select-display::after { content: ''; position: absolute; right: 0.65rem; top: 50%; width: 0.4rem; height: 0.4rem; margin-top: -0.3rem; border-right: 2px solid var(--color-fg-subtle); border-bottom: 2px solid var(--color-fg-subtle); transform: rotate(45deg); }
515
+ .cs-select-wrap > .cs-select-native { position: absolute; inset: 0; width: 100%; height: 100%; max-width: none; margin: 0; padding: 0 0.5rem; box-sizing: border-box; background: transparent; color: transparent; border-color: transparent; cursor: pointer; appearance: none; -webkit-appearance: none; }
516
+ .cs-select-native option, .cs-select-native optgroup { color: var(--color-fg); background: var(--color-surface-2); }
517
+ .cs-select-wrap > .cs-select-native:focus { outline: none; }
518
+ .cs-select-wrap:focus-within > .cs-select-display { outline: 2px solid var(--color-accent); outline-offset: 2px; }
519
+ @supports selector(:has(*)) {
520
+ .cs-select-wrap:focus-within > .cs-select-display { outline: none; }
521
+ .cs-select-wrap:has(> .cs-select-native:focus-visible) > .cs-select-display { outline: 2px solid var(--color-accent); outline-offset: 2px; }
522
+ }
523
+ /* Windows High Contrast forces a select's text back to CanvasText, which would paint its one cut line over the display's lines (the re-review of 0.17.0, B1) */
524
+ @media (forced-colors: active) {
525
+ .cs-select-wrap > .cs-select-native { forced-color-adjust: none; }
526
+ .cs-select-native option, .cs-select-native optgroup { forced-color-adjust: auto; }
527
+ }
528
+ /* the select and a control beside it (a product's accessory): one row, the select shrinks, the control keeps its size */
512
529
  .cs-select-row { display: flex; align-items: center; gap: 0.5rem; min-width: 0; }
513
- .cs-select-row > .cs-select { flex: 1 1 auto; min-width: 0; }
514
- .cs-select-row > :not(.cs-select) { flex: none; }
530
+ .cs-select-row > .cs-select-wrap { flex: 1 1 auto; min-width: 0; }
531
+ .cs-select-row > :not(.cs-select-wrap) { flex: none; }
515
532
 
516
533
  /* == 9 workbench ================================================================================================ */
517
534
 
@@ -837,3 +854,28 @@ svg.lucide { flex: none; }
837
854
  @media (max-width: 900px) {
838
855
  .caos-focus-instrument > .caos-cw-focus { padding: var(--caos-focus-clear-top, 6.6rem) 0.5rem max(0.5rem, var(--caos-focus-clear-bottom, 0px)); }
839
856
  }
857
+
858
+ /* == Focus mode for a SurfacePage App (shell 0.16.0, ADR-0070) ======================================================== */
859
+
860
+ /* The stage is the open view, laid out as CaseWorkbench's group is (the same clearance under the Focus chrome); a view with no rail
861
+ leaves the frame one column, so the stage is the whole frame. */
862
+ .caos-focus-instrument > .caos-surface-focus { position: absolute; inset: 0; display: flex; flex-direction: column; min-width: 0; min-height: 0; padding: var(--caos-focus-clear-top, 4.6rem) 0.9rem max(0.9rem, var(--caos-focus-clear-bottom, 0px)); }
863
+ .caos-surface-focus > .tabs { display: flex; flex-direction: column; min-height: 0; flex: 1 1 auto; }
864
+ .caos-surface-focus > .tabs > .tabpanel:not([hidden]) { flex: 1 1 auto; min-height: 0; overflow: auto; display: flex; flex-direction: column; gap: 0.6rem; }
865
+ .caos-focus-shell.caos-focus-norail { grid-template-columns: minmax(0,1fr); }
866
+ @media (max-width: 900px) {
867
+ .caos-focus-instrument > .caos-surface-focus { padding: var(--caos-focus-clear-top, 6.6rem) 0.5rem max(0.5rem, var(--caos-focus-clear-bottom, 0px)); }
868
+ .caos-focus-shell.caos-focus-norail { grid-template-rows: minmax(0,1fr); }
869
+ }
870
+ /* The Focus control of a surface sits at the end of the tab row, in a slot beside the tab list, on the same row (never in the head, never
871
+ inside the tablist). The slot is always there while the surface has Focus, so the row does not move between views; the tabs scroll
872
+ beside it. The slot repeats the tab list's bottom rule, so the line runs under both. */
873
+ .caos-surface-body > .tabs.tabs-has-slot { display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-rows: auto minmax(0, 1fr); column-gap: 0.5rem; }
874
+ .tabs-has-slot > .tablist { grid-column: 1; grid-row: 1; }
875
+ .tabs-has-slot > .tabs-slot { grid-column: 2; grid-row: 1; align-self: stretch; display: flex; align-items: center; padding-bottom: 0.4rem; margin-bottom: 0.9rem; border-bottom: 1px solid var(--color-border); }
876
+ .tabs-has-slot > .tabs-slot > .caos-focus-enter { align-self: center; padding-block: 0.2rem; }
877
+ .tabs-has-slot > .tabpanel { grid-column: 1 / -1; grid-row: 2; }
878
+ /* The same slot in the workbench's group tab row (shell 0.17.0, BL-074): the Focus control of the App ends the row, so the case select keeps
879
+ the rail head's whole width. The tab row's own spacing (a tighter rule than the surface's) is repeated under the slot. */
880
+ .caos-cw > .tabs.tabs-has-slot { display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-rows: auto minmax(0, 1fr); column-gap: 0.5rem; }
881
+ .caos-cw > .tabs-has-slot > .tabs-slot { margin-bottom: 0.5rem; padding-bottom: var(--space-1); }