@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/bin/caos-shell-gate.mjs +9 -3
- package/dist/chart.d.ts +41 -2
- package/dist/chart.js +206 -20
- package/dist/index.d.ts +137 -48
- package/dist/index.js +220 -97
- package/gate/inpage.mjs +77 -3
- package/gate/run.mjs +129 -13
- package/gate/serve.mjs +13 -3
- package/package.json +3 -4
- package/reserved-classes.json +16 -2
- package/styles.css +46 -4
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,
|
|
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,
|
|
857
|
-
const
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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.
|
|
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": "
|
|
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",
|
package/reserved-classes.json
CHANGED
|
@@ -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
|
-
|
|
511
|
-
|
|
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); }
|