@fasl-work/caos-app-shell 0.6.6 → 0.6.8

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/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createContext, useState, useRef, useEffect, useCallback, useId, useContext } from 'react';
1
+ import { createContext, useState, useRef, useId, useEffect, useCallback, useContext } from 'react';
2
2
  import { useLocation, NavLink } from 'react-router';
3
3
  import { Sun, Moon, Languages, Boxes, CodeXml, Globe, Briefcase, Info } from 'lucide-react';
4
4
  import { create } from 'zustand';
@@ -127,19 +127,53 @@ function ArchitectureModal({ config, onClose }) {
127
127
  const [active, setActive] = useState(tabs[0]?.id ?? "");
128
128
  const [svg, setSvg] = useState(null);
129
129
  const [err, setErr] = useState(null);
130
+ const [fullSize, setFullSize] = useState(false);
131
+ const [nativeWidth, setNativeWidth] = useState(800);
132
+ const modalRef = useRef(null);
133
+ const diagramRef = useRef(null);
130
134
  const closeRef = useRef(null);
135
+ const latestClose = useRef(onClose);
136
+ latestClose.current = onClose;
137
+ const id = useId();
131
138
  useEffect(() => {
139
+ const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
140
+ const focusable = () => [...modalRef.current?.querySelectorAll(
141
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
142
+ ) ?? []].filter((element) => element.tabIndex >= 0 && element.getClientRects().length > 0);
132
143
  const onKey = (e) => {
133
- if (e.key === "Escape") onClose();
144
+ if (e.key === "Escape") {
145
+ e.preventDefault();
146
+ e.stopPropagation();
147
+ latestClose.current();
148
+ }
149
+ if (e.key !== "Tab") return;
150
+ const elements = focusable(), first = elements[0], last = elements.at(-1);
151
+ if (!first || !last) {
152
+ e.preventDefault();
153
+ return;
154
+ }
155
+ if (!modalRef.current?.contains(document.activeElement) || (e.shiftKey ? document.activeElement === first : document.activeElement === last)) {
156
+ e.preventDefault();
157
+ (e.shiftKey ? last : first).focus();
158
+ }
134
159
  };
135
- window.addEventListener("keydown", onKey);
160
+ const onFocus = (e) => {
161
+ if (e.target instanceof Node && !modalRef.current?.contains(e.target)) closeRef.current?.focus();
162
+ };
163
+ window.addEventListener("keydown", onKey, true);
164
+ document.addEventListener("focusin", onFocus);
136
165
  closeRef.current?.focus();
137
- return () => window.removeEventListener("keydown", onKey);
138
- }, [onClose]);
166
+ return () => {
167
+ window.removeEventListener("keydown", onKey, true);
168
+ document.removeEventListener("focusin", onFocus);
169
+ if (opener?.isConnected) opener.focus();
170
+ };
171
+ }, []);
139
172
  const tab = tabs.find((t) => t.id === active) ?? tabs[0];
140
173
  useEffect(() => {
141
174
  if (!tab) return;
142
175
  setErr(null);
176
+ setFullSize(false);
143
177
  const raw = tab.svg.trim();
144
178
  if (raw.startsWith("<svg")) {
145
179
  setSvg(raw);
@@ -166,12 +200,19 @@ function ArchitectureModal({ config, onClose }) {
166
200
  cancelled = true;
167
201
  };
168
202
  }, [tab]);
203
+ useEffect(() => {
204
+ const element = diagramRef.current?.querySelector("svg");
205
+ if (!element) return;
206
+ const width = element.viewBox.baseVal.width || element.width.baseVal.value;
207
+ setNativeWidth(Number.isFinite(width) && width > 0 ? width : 800);
208
+ }, [svg]);
169
209
  if (!tab) return null;
170
210
  const title = (es ? config.title_es : config.title_en) ?? (es ? "Arquitectura / C\xF3mo funciona" : "Architecture / How it works");
171
211
  const body = (es ? tab.body_es : tab.body_en).split(/\n\s*\n/);
172
212
  return /* @__PURE__ */ jsx(
173
213
  "div",
174
214
  {
215
+ ref: modalRef,
175
216
  role: "dialog",
176
217
  "aria-modal": "true",
177
218
  "aria-label": title,
@@ -204,9 +245,20 @@ function ArchitectureModal({ config, onClose }) {
204
245
  return /* @__PURE__ */ jsx(
205
246
  "button",
206
247
  {
248
+ id: `${id}-${t.id}`,
207
249
  role: "tab",
208
250
  "aria-selected": sel,
251
+ "aria-controls": `${id}-panel`,
252
+ tabIndex: sel ? 0 : -1,
209
253
  onClick: () => setActive(t.id),
254
+ onKeyDown: (event) => {
255
+ const index = tabs.indexOf(t);
256
+ const next = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : event.key === "ArrowRight" ? (index + 1) % tabs.length : event.key === "ArrowLeft" ? (index - 1 + tabs.length) % tabs.length : -1;
257
+ if (next < 0) return;
258
+ event.preventDefault();
259
+ setActive(tabs[next].id);
260
+ document.getElementById(`${id}-${tabs[next].id}`)?.focus();
261
+ },
210
262
  style: {
211
263
  fontSize: 12.5,
212
264
  padding: "5px 10px",
@@ -223,20 +275,25 @@ function ArchitectureModal({ config, onClose }) {
223
275
  t.id
224
276
  );
225
277
  }) }),
226
- /* @__PURE__ */ jsxs("div", { role: "tabpanel", "data-arch-lang": es ? "es" : "en", style: { overflowY: "auto", padding: "14px 16px 18px" }, children: [
278
+ /* @__PURE__ */ jsxs("div", { id: `${id}-panel`, role: "tabpanel", "aria-labelledby": `${id}-${tab.id}`, "data-arch-lang": es ? "es" : "en", style: { overflowY: "auto", minHeight: 0, padding: "14px 16px 18px" }, children: [
227
279
  body.map((p, i) => /* @__PURE__ */ jsx("p", { style: { color: "var(--color-fg)", fontSize: 13, lineHeight: 1.65, margin: i === 0 ? "0 0 10px" : "10px 0" }, children: p }, i)),
228
280
  err && /* @__PURE__ */ jsxs("div", { style: { color: "var(--color-warn, #f87171)", fontSize: 12 }, children: [
229
281
  "SVG: ",
230
282
  err
231
283
  ] }),
232
284
  !err && svg === null && /* @__PURE__ */ jsx("div", { style: { color: "var(--color-fg-faint)", fontSize: 12 }, children: "\u2026" }),
233
- svg !== null && /* @__PURE__ */ jsx(
234
- "div",
235
- {
236
- style: { border: "1px solid var(--color-border)", borderRadius: 8, padding: 8, background: "var(--color-bg, var(--color-surface))", overflowX: "auto" },
237
- dangerouslySetInnerHTML: { __html: svg }
238
- }
239
- )
285
+ svg !== null && /* @__PURE__ */ jsxs(Fragment, { children: [
286
+ /* @__PURE__ */ jsx("button", { type: "button", "aria-pressed": fullSize, "aria-controls": `${id}-diagram`, onClick: () => setFullSize((value) => !value), style: { margin: "0 0 8px", fontSize: 12, padding: "5px 10px" }, children: fullSize ? es ? "Ajustar diagrama" : "Fit diagram" : es ? "Leer a tama\xF1o completo" : "Read at full size" }),
287
+ /* @__PURE__ */ jsx("div", { id: `${id}-diagram`, role: "region", "aria-label": es ? "Diagrama de arquitectura" : "Architecture diagram", tabIndex: 0, style: { border: "1px solid var(--color-border)", borderRadius: 8, padding: 8, background: "var(--color-bg, var(--color-surface))", overflow: "auto", maxWidth: "100%" }, children: /* @__PURE__ */ jsx(
288
+ "div",
289
+ {
290
+ ref: diagramRef,
291
+ className: "caos-architecture-diagram",
292
+ style: { width: fullSize ? nativeWidth : "100%", maxWidth: fullSize ? "none" : "100%" },
293
+ dangerouslySetInnerHTML: { __html: svg }
294
+ }
295
+ ) })
296
+ ] })
240
297
  ] })
241
298
  ]
242
299
  }
@@ -267,6 +324,7 @@ function AppShell({ config, children }) {
267
324
  {
268
325
  to: r.path,
269
326
  end: r.path === "/",
327
+ onFocus: (event) => event.currentTarget.scrollIntoView({ block: "nearest", inline: "nearest" }),
270
328
  className: ({ isActive }) => isActive ? "nav-link active" : "nav-link",
271
329
  children: lang === "es" ? r.es : r.en
272
330
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fasl-work/caos-app-shell",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
4
4
  "description": "Shared web-app shell + content primitives + design system for the CAOS / Faena public apps (ADR-0016).",
5
5
  "license": "MIT",
6
6
  "author": "Felipe Santibáñez-Leal",
@@ -29,6 +29,7 @@
29
29
  "scripts": {
30
30
  "build": "tsup",
31
31
  "test": "node --import tsx --test \"test/**/*.test.ts\" \"test/**/*.test.tsx\"",
32
+ "test:browser": "node --import tsx --test \"e2e/**/*.test.ts\"",
32
33
  "prepublishOnly": "npm run build"
33
34
  },
34
35
  "peerDependencies": {
@@ -40,6 +41,7 @@
40
41
  "zustand": "^4 || ^5"
41
42
  },
42
43
  "devDependencies": {
44
+ "@playwright/test": "1.63.0",
43
45
  "@types/katex": "^0.16.7",
44
46
  "@types/react": "^19.0.0",
45
47
  "@types/react-dom": "^19.0.0",
package/styles.css CHANGED
@@ -110,7 +110,13 @@ code, kbd { font-family: var(--font-mono); }
110
110
  /* ── Misc ── */
111
111
  .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
112
112
  :where(a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])):focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; border-radius: 4px; }
113
- @media (max-width: 760px) { .main-nav { display: none; } }
113
+ @media (max-width: 760px) {
114
+ .header-inner { height: auto; min-height: 56px; flex-wrap: wrap; gap: 0 0.5rem; padding: 4px 1rem; }
115
+ .header-actions { gap: 0; }
116
+ .header-actions .icon-btn { padding: 0.35rem; }
117
+ .main-nav { order: 3; flex: 0 0 100%; min-width: 0; overflow-x: auto; white-space: nowrap; padding: 3px 0; scrollbar-width: thin; }
118
+ .main-nav .nav-link { flex: none; padding: 0.3rem 0.6rem; font-size: 0.8rem; }
119
+ }
114
120
  @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }
115
121
 
116
122
  /* ── Prose / content pages ── (fill the page-body width with grids/figures/sub-tab rails, not a narrow column) */
@@ -327,6 +333,7 @@ html, body { overflow-x: hidden; }
327
333
  [data-arch-lang] .l-es { display: none; }
328
334
  [data-arch-lang="es"] .l-en { display: none; }
329
335
  [data-arch-lang="es"] .l-es { display: inline; }
336
+ .caos-architecture-diagram > svg { display: block; width: 100%; height: auto; max-width: none; }
330
337
 
331
338
  /* The compact one-of-N case picker (ADR-0071 rule 7). Ten cases in four categories
332
339
  cost a 308px chip block, 21% of a 2560x1440 viewport, and one row as a dropdown. */