@1agh/maude 0.53.0 → 0.53.2

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.
Files changed (30) hide show
  1. package/apps/studio/bin/_import-asset.mjs +1 -6
  2. package/apps/studio/bin/_import-brand.mjs +1 -1
  3. package/apps/studio/bin/_video-playwright.mjs +5 -1
  4. package/apps/studio/bin/screenshot.sh +131 -15
  5. package/apps/studio/canvas-build-sandbox.ts +385 -0
  6. package/apps/studio/canvas-build-worker.ts +85 -0
  7. package/apps/studio/client/app.jsx +112 -8
  8. package/apps/studio/client/canvas-url.js +7 -0
  9. package/apps/studio/client/panels/SettingsPanel.jsx +32 -12
  10. package/apps/studio/client/report-bug.jsx +125 -30
  11. package/apps/studio/client/styles/4-components-maude.css +5 -0
  12. package/apps/studio/client/styles/4-components.css +35 -0
  13. package/apps/studio/dist/client.bundle.js +831 -831
  14. package/apps/studio/dist/styles.css +1 -1
  15. package/apps/studio/http.ts +252 -23
  16. package/apps/studio/paths.ts +8 -0
  17. package/apps/studio/server.ts +102 -36
  18. package/apps/studio/test/canvas-build-sandbox.test.ts +69 -0
  19. package/apps/studio/test/canvas-origin-gate.test.ts +5 -0
  20. package/apps/studio/test/canvas-url.test.ts +26 -0
  21. package/apps/studio/test/cloud-session-role.test.ts +131 -0
  22. package/apps/studio/test/cloud-shell-surfaces.test.ts +90 -0
  23. package/apps/studio/test/config-projection.test.ts +104 -0
  24. package/apps/studio/test/read-only-gate.test.ts +50 -0
  25. package/apps/studio/test/report-proxy.test.ts +125 -0
  26. package/apps/studio/test/workspace-containment.test.ts +90 -11
  27. package/apps/studio/whats-new.json +11 -2
  28. package/apps/studio/workspace-mode.ts +161 -28
  29. package/apps/studio/ws.ts +30 -0
  30. package/package.json +8 -8
@@ -0,0 +1,85 @@
1
+ // The cell's canvas build, in its own process — Cloud Phase 25 A1, moved to the
2
+ // studio by Cloud Phase 27 / DDR-209 A′2.
3
+ //
4
+ // WHY A SEPARATE PROCESS, AND WHY BUN.
5
+ //
6
+ // Phase 25 A0 decided the cell BUILDS a tenant's canvas and the member's browser
7
+ // EVALUATES it. "Build is not evaluation" is only true if the build cannot be
8
+ // made to do anything else, so the build runs where it can be bounded:
9
+ //
10
+ // - its own process, spawned with an EMPTY environment — the derived cell
11
+ // secret, the project token key and the tenant's storage credentials are
12
+ // all env vars in the server, and none of them exist here;
13
+ // - a wall-clock deadline and an RSS ceiling enforced by the parent, so a
14
+ // pathological import graph costs one killed process, not the cell;
15
+ // - an import allowlist (canvas-build.ts `restrictImportsTo`), so a tenant's
16
+ // source can reach the runtime packages, `@maude/canvas-lib`, and its own
17
+ // files — and nothing else on this disk.
18
+ //
19
+ // WHY IT MOVED. It used to live at `apps/hub/src/canvas/build-worker.ts` and
20
+ // import this engine across the repo. DDR-209 A′2 deletes the hub's canvas
21
+ // implementation and runs the REAL studio in the cell, so the host has to live
22
+ // where the route it protects lives. Nothing about the contract changed — the
23
+ // empty env, the allowlist and the ceilings are the same ones, and
24
+ // `scripts/check-containment.sh` still asserts every one of them.
25
+ //
26
+ // It runs under BUN rather than the hub's Node so the output is the SAME
27
+ // artifact the desktop produces: same `Bun.hash`-derived `data-cd-id`s (so a
28
+ // comment anchored in the browser resolves in the desktop and back), and the
29
+ // same `Bun.build` bundle. The alternative — esbuild — was measured at 0.6%
30
+ // median drift in the Phase 21 spike; identical beats measured-close, and it
31
+ // removes a whole class of "renders differently in the browser" bug reports.
32
+ //
33
+ // Protocol: argv gives the design root and the canvas path; stdout carries one
34
+ // JSON object. Nothing else is printed on the happy path, so the parent parses
35
+ // stdout wholesale.
36
+
37
+ import { join } from 'node:path';
38
+
39
+ const [, , designRoot, canvasAbs] = process.argv;
40
+
41
+ /**
42
+ * Where the build engine lives.
43
+ *
44
+ * A DYNAMIC import, not a static one, because this file runs from two very
45
+ * different layouts: a dev checkout (a sibling of `canvas-build.ts`) and the
46
+ * cell image, where the Dockerfile stages the studio source wherever it likes.
47
+ * `MAUDE_STUDIO_SRC` is passed through the sandbox's otherwise-empty environment
48
+ * on purpose — it is a path, not a secret.
49
+ *
50
+ * `import.meta.dir`, NOT `paths.ts`: this file is deliberately dependency-free
51
+ * so the sandboxed child imports the engine and nothing else. DDR-045's rule is
52
+ * about the SERVER's disk paths under `bun --compile`; the worker is never
53
+ * compiled — it is always run as source by an explicit `bun <path>` the parent
54
+ * resolved through `paths.ts` already.
55
+ */
56
+ function studioDir(): string {
57
+ return process.env.MAUDE_STUDIO_SRC || import.meta.dir;
58
+ }
59
+
60
+ async function main() {
61
+ if (!designRoot || !canvasAbs) {
62
+ process.stdout.write(JSON.stringify({ ok: false, error: 'usage: <designRoot> <canvasAbs>' }));
63
+ process.exit(2);
64
+ }
65
+ const { buildCanvasModule } = await import(join(studioDir(), 'canvas-build.ts'));
66
+ const source = await Bun.file(canvasAbs).text();
67
+ const built = await buildCanvasModule(canvasAbs, source, {
68
+ designRoot,
69
+ restrictImportsTo: designRoot,
70
+ });
71
+ process.stdout.write(
72
+ JSON.stringify({ ok: true, js: built.js, locator: built.locator, etag: built.etag })
73
+ );
74
+ }
75
+
76
+ main().catch((err) => {
77
+ // The message is the product surface: a rejected import, a syntax error or a
78
+ // missing sibling all arrive here, and the person who wrote the canvas is
79
+ // the one who has to act on it. Bun collapses plugin throws into the build
80
+ // log, so the allowlist's own wording survives in `err.message`.
81
+ process.stdout.write(
82
+ JSON.stringify({ ok: false, error: String(err?.message ?? err).slice(0, 4000) })
83
+ );
84
+ process.exit(1);
85
+ });
@@ -2429,6 +2429,8 @@ function Sidebar({
2429
2429
  // Cloud Phase 25 C2 — viewer role: create / delete / move / rename
2430
2430
  // affordances are absent (buttons, composer, row menus, drag & drop).
2431
2431
  readOnly = false,
2432
+ /** `{ dashboardUrl, projectName }` when this is a cloud tab, else null. */
2433
+ cloud = null,
2432
2434
  groups,
2433
2435
  activePath,
2434
2436
  activeDsName,
@@ -2825,7 +2827,12 @@ function Sidebar({
2825
2827
  {/* Cloud Phase 23 C3 — Maude Cloud sign-in + remote-project attach, docked
2826
2828
  above the GitHub identity. Dev-server-backed, so it works in the desktop
2827
2829
  shell AND a plain browser. */}
2828
- <CloudBar />
2830
+ {/* Cloud Phase 27 — "Sign in to Maude Cloud" is an offer to do the thing
2831
+ you have already done: you reached this tab THROUGH a Maude account.
2832
+ Absent rather than disabled, because unlike the agent chat there is
2833
+ nothing here to explain — the capability is not missing, it is
2834
+ already satisfied. */}
2835
+ {cloud ? null : <CloudBar />}
2829
2836
  {/* Phase 28 (E3) — GitHub identity as a compact avatar docked at the BOTTOM:
2830
2837
  sign in, connected account + New/Pull/Share, sign out. Self-contained
2831
2838
  (owns its device-code + CreateProject dialogs). Renders nothing in browser. */}
@@ -3353,11 +3360,21 @@ function ViewDropdown({ panels, onToggle, onClose, onZoom, hasCanvas }) {
3353
3360
  role="menuitem"
3354
3361
  className={'st-dd-item' + (p.checked ? ' is-on' : '')}
3355
3362
  aria-disabled={p.disabled ? 'true' : undefined}
3363
+ title={p.hint || undefined}
3356
3364
  onClick={() => {
3357
- if (!p.disabled) {
3358
- onToggle(p.id);
3359
- onClose();
3365
+ // Cloud Phase 27 C2 — a row that states an absence still has
3366
+ // somewhere to send you. A disabled item with a `href` opens it
3367
+ // instead of doing nothing, which is the difference between "this
3368
+ // is not available here" and a dead button.
3369
+ if (p.disabled) {
3370
+ if (p.href) {
3371
+ window.open(p.href, '_blank', 'noopener,noreferrer');
3372
+ onClose();
3373
+ }
3374
+ return;
3360
3375
  }
3376
+ onToggle(p.id);
3377
+ onClose();
3361
3378
  }}
3362
3379
  >
3363
3380
  <span className="st-dd-lead">
@@ -3604,6 +3621,8 @@ function EditDropdown({ onAction, onClose, hasCanvas, readOnly = false }) {
3604
3621
  function Menubar({
3605
3622
  activePath,
3606
3623
  project,
3624
+ /** `{ dashboardUrl, projectName }` when this is a cloud tab, else null. */
3625
+ cloud = null,
3607
3626
  tabsCount,
3608
3627
  openMenu,
3609
3628
  setOpenMenu,
@@ -3678,8 +3697,19 @@ function Menubar({
3678
3697
  <span style={{ color: 'var(--u-fg-3)' }}>no canvas open</span>
3679
3698
  );
3680
3699
 
3681
- // Cloud Phase 25 C2 — panels a viewer's session doesn't have (edit chrome).
3682
- const viewerHiddenPanels = new Set(['assistant', 'inspector', 'layers', 'autoopen']);
3700
+ // Cloud Phase 27 C1 — ROLE SHAPES WHAT IS OFFERED, NEVER WHAT IS VISIBLE.
3701
+ //
3702
+ // `inspector` and `layers` used to be on this list and should never have been.
3703
+ // Read-only means cannot CHANGE, not cannot SEE: a reviewer needs the
3704
+ // structure and the measured values to say anything useful, and hiding them
3705
+ // is how a viewer's Maude became a worse Maude rather than a narrower one.
3706
+ // What stays hidden is only what genuinely does not exist for this session —
3707
+ // the agent chat (`assistant`, which needs a `claude` on the machine the app
3708
+ // is running on) and auto-open (a preference for a surface you cannot edit).
3709
+ // In the cloud the `assistant` row is the stated-absence row above, so it must
3710
+ // NOT be filtered away here — a viewer would then get silence, which is the
3711
+ // exact thing C2 forbids.
3712
+ const viewerHiddenPanels = new Set(cloud ? ['autoopen'] : ['assistant', 'autoopen']);
3683
3713
  const panels = [
3684
3714
  { id: 'tree', label: 'Project Tree', shortcut: 'T', checked: sidebarOpen, disabled: false },
3685
3715
  {
@@ -3745,7 +3775,26 @@ function Menubar({
3745
3775
  disabled: false,
3746
3776
  },
3747
3777
  ]
3748
- : []),
3778
+ : // Cloud Phase 27 C2 — THE AGENT'S ABSENCE IS STATED WHERE THE AGENT
3779
+ // WOULD BE. It runs on YOUR `claude` subscription, on YOUR machine
3780
+ // (DDR-123), so a browser tab genuinely cannot have it. That is a
3781
+ // legitimate difference — but a menu that silently lacks an item its
3782
+ // user has seen on the desktop reads as "the cloud one is broken", so
3783
+ // the row stays, disabled, saying where to find it. Never a hidden
3784
+ // item, never a dead button, never silence.
3785
+ cloud
3786
+ ? [
3787
+ {
3788
+ id: 'assistant',
3789
+ label: 'Assistant — in the desktop app',
3790
+ shortcut: '',
3791
+ checked: false,
3792
+ disabled: true,
3793
+ href: 'https://maude.sh/download',
3794
+ hint: 'The agent runs on your own Claude subscription, on your own machine.',
3795
+ },
3796
+ ]
3797
+ : []),
3749
3798
  {
3750
3799
  id: 'annotate',
3751
3800
  label: 'Annotations',
@@ -3848,6 +3897,29 @@ function Menubar({
3848
3897
  </span>
3849
3898
  <span className="st-brand-name">maude</span>
3850
3899
  </span>
3900
+ {/* Cloud Phase 27 C4 — THE WAY BACK OUT.
3901
+ A browser tab has no window title and no app switcher, so a teammate
3902
+ who followed a link has nothing telling them which project they are in
3903
+ or how to leave it. The desktop gets both for free from the OS; the
3904
+ cloud has to say them. This is an ADDITION to the shared client behind
3905
+ the same `cloud` flag as C2's agent notice — never a fork of the
3906
+ component, which is how the two shells started diverging last time. */}
3907
+ {cloud ? (
3908
+ <span className="st-cloudback" data-testid="cloud-back">
3909
+ <a
3910
+ className="st-cloudback-link"
3911
+ href={cloud.dashboardUrl || 'https://cloud.maude.sh'}
3912
+ title="Back to your projects"
3913
+ >
3914
+ ← Dashboard
3915
+ </a>
3916
+ {cloud.projectName ? (
3917
+ <span className="st-cloudback-project" data-testid="cloud-project-name">
3918
+ {cloud.projectName}
3919
+ </span>
3920
+ ) : null}
3921
+ </span>
3922
+ ) : null}
3851
3923
  <nav className="st-menus" aria-label="Application menus" data-tour="menus">
3852
3924
  {MENU_NAMES.map((name) => {
3853
3925
  const key = name.toLowerCase();
@@ -9196,6 +9268,23 @@ function App() {
9196
9268
  // decides what the UI offers; the cell (C1) and the dev-server's
9197
9269
  // read-only gate (http.ts) are what actually stop a write.
9198
9270
  readOnly: !!data.readOnly,
9271
+ // Cloud Phase 27 (DDR-209) — the capability that opens the cookieless
9272
+ // canvas origin. `canvasUrl()` appends it to every iframe URL. Absent
9273
+ // on a desktop, where the canvas origin is loopback and needs none.
9274
+ //
9275
+ // NOTE FOR WHOEVER ADDS THE NEXT FIELD: this is an explicit
9276
+ // projection, not a spread of `data`. A field the server starts
9277
+ // returning does NOT arrive here until it is named — which is how
9278
+ // both of these went missing and the cloud studio rendered without
9279
+ // its chrome and with every canvas iframe unauthenticated.
9280
+ canvasToken: data.canvasToken,
9281
+ // C2/C4 — `{ dashboardUrl, projectName }` when this is a cloud tab.
9282
+ // Its presence is what tells the shared client it is in a browser tab
9283
+ // on somebody else's machine.
9284
+ cloud: data.cloud ?? null,
9285
+ // C2/C4 — `{ dashboardUrl, projectName }` when this is a cloud tab.
9286
+ // Its presence is what tells the shared client it is in a browser tab
9287
+ // on somebody else's machine.
9199
9288
  }));
9200
9289
  })
9201
9290
  .catch(() => {});
@@ -13759,6 +13848,7 @@ function App() {
13759
13848
  if (id === 'tree')
13760
13849
  return (
13761
13850
  <Sidebar
13851
+ cloud={cfg?.cloud ?? null}
13762
13852
  readOnly={viewerMode}
13763
13853
  groups={groups}
13764
13854
  activePath={activePath}
@@ -13920,6 +14010,10 @@ function App() {
13920
14010
  <div className="st-shell">
13921
14011
  <Menubar
13922
14012
  readOnly={viewerMode}
14013
+ // Cloud Phase 27 C2/C4 — the ONE cloud-only input the shared chrome
14014
+ // takes. A prop, not the whole `cfg`: the Menubar needs to know it is
14015
+ // in a browser tab on somebody else's machine, and nothing else.
14016
+ cloud={cfg?.cloud ?? null}
13923
14017
  activePath={activePath}
13924
14018
  project={project}
13925
14019
  tabsCount={tabs.length}
@@ -14919,6 +15013,7 @@ function App() {
14919
15013
  )}
14920
15014
  {settingsOpen && (
14921
15015
  <SettingsPanel
15016
+ cloud={cfg?.cloud ?? null}
14922
15017
  onClose={() => setSettingsOpen(false)}
14923
15018
  initialTab={typeof settingsOpen === 'string' ? settingsOpen : undefined}
14924
15019
  theme={theme}
@@ -14994,7 +15089,16 @@ function App() {
14994
15089
  startTour(USAGE_TOUR);
14995
15090
  }}
14996
15091
  />
14997
- <ReportBugDialog open={reportBugOpen} onClose={() => setReportBugOpen(false)} />
15092
+ {/* `activeCanvas` is THIS window's truth. The server's `_active.json` is
15093
+ global and sticky — it survives closing every tab and is written by any
15094
+ other session (an agent driving a headless browser, a second window),
15095
+ so a report built from it can claim a canvas the user does not have
15096
+ open, and capture it. Pass what this client actually shows. */}
15097
+ <ReportBugDialog
15098
+ open={reportBugOpen}
15099
+ activeCanvas={activePath}
15100
+ onClose={() => setReportBugOpen(false)}
15101
+ />
14998
15102
  <WhatsNewPanel wn={whatsNew} onStartTour={startTour} />
14999
15103
  <ExportPanel center={exportCenter} />
15000
15104
  <ReadinessDialog open={readinessOpen} onClose={() => setReadinessOpen(false)} />
@@ -41,6 +41,13 @@ export function canvasUrl(p, cfg, opts) {
41
41
  // reads this flag). Boot-static on purpose — a role is per-session, and a
42
42
  // query param can't race the way a post-load message could.
43
43
  if (cfg?.readOnly) params.set('ro', '1');
44
+ // Cloud Phase 27 (DDR-209) — the canvas origin's capability. On a desktop the
45
+ // canvas origin is loopback and needs none, so this is absent and the URL is
46
+ // byte-identical to before. In the cloud it is a cookieless, cross-site
47
+ // origin: a cookie scoped widely enough to cover it would be readable by the
48
+ // untrusted canvas content itself, which is the one thing the DDR-054 split
49
+ // exists to prevent — so the capability lives in the URL instead.
50
+ if (cfg?.canvasToken) params.set('t', cfg.canvasToken);
44
51
  const ds0 = cfg?.designSystems?.[0];
45
52
  // Specimen detection: anything under `system/<ds>/preview/` belongs to that
46
53
  // specific DS, so it must render with *that* DS's tokens — not always the
@@ -11,7 +11,7 @@
11
11
  // (st-scrim / st-dialog). Icons are local Lucide-line paths (IdentityBar
12
12
  // precedent); colors are theme tokens only (no hardcoded hex).
13
13
 
14
- import { useCallback, useEffect, useState } from 'react';
14
+ import { useCallback, useEffect, useMemo, useState } from 'react';
15
15
 
16
16
  function Icon({ name, size = 16 }) {
17
17
  const p = {
@@ -588,9 +588,21 @@ const TABS = [
588
588
  { id: 'appearance', label: 'Appearance' },
589
589
  { id: 'canvas-view', label: 'Canvas & View' },
590
590
  { id: 'layout', label: 'Layout' },
591
- { id: 'ai-generation', label: 'AI generation' },
592
- { id: 'subtitles', label: 'Subtitles' },
593
- { id: 'video', label: 'Video' },
591
+ // Cloud Phase 27 — `local: true` means "this tab configures something that
592
+ // runs on the machine Maude is installed on". A browser tab has no ffmpeg, no
593
+ // mlx-vlm, no HuggingFace cache and no provider key of yours, and the routes
594
+ // behind these panes are refused in a cell by design (DDR-209 D1) — so in the
595
+ // cloud they rendered as a wall of `HTTP 404`.
596
+ //
597
+ // ABSENT, not disabled. C2 says state an absence where the thing would be,
598
+ // and that rule earns its keep for the agent chat: the feature exists, you
599
+ // just need the desktop app. These are settings for machinery that is not
600
+ // missing so much as inapplicable — a preference pane for hardware this shell
601
+ // does not have. There is nothing for the reader to act on, so a whole tab of
602
+ // dead controls is worse than no tab.
603
+ { id: 'ai-generation', label: 'AI generation', local: true },
604
+ { id: 'subtitles', label: 'Subtitles', local: true },
605
+ { id: 'video', label: 'Video', local: true },
594
606
  ];
595
607
  const SETTINGS_TAB_STORE = 'mdcc-settings-tab';
596
608
 
@@ -789,6 +801,8 @@ function AppearanceTab({ theme, onSetTheme, cpMode, onSetCpMode }) {
789
801
  }
790
802
 
791
803
  export default function SettingsPanel({
804
+ /** `{ dashboardUrl, projectName }` when this is a cloud tab, else null. */
805
+ cloud = null,
792
806
  onClose,
793
807
  initialTab,
794
808
  theme,
@@ -811,11 +825,17 @@ export default function SettingsPanel({
811
825
  }) {
812
826
  const [providers, setProviders] = useState(null); // null = loading
813
827
  const [error, setError] = useState(null);
828
+ // The tabs THIS shell can honour. See TABS for why the local-machine ones are
829
+ // absent in a browser tab rather than disabled.
830
+ const tabs = useMemo(() => TABS.filter((t) => !(cloud && t.local)), [cloud]);
814
831
  const [tab, setTab] = useState(() => {
815
- if (initialTab && TABS.some((t) => t.id === initialTab)) return initialTab;
832
+ // A remembered tab that this shell does not have (you opened Video on the
833
+ // desktop, then opened Settings in the cloud) must not select nothing.
834
+ const has = (id) => TABS.some((t) => t.id === id && !(cloud && t.local));
835
+ if (initialTab && has(initialTab)) return initialTab;
816
836
  try {
817
837
  const v = localStorage.getItem(SETTINGS_TAB_STORE);
818
- if (v && TABS.some((t) => t.id === v)) return v;
838
+ if (v && has(v)) return v;
819
839
  } catch {}
820
840
  return 'appearance';
821
841
  });
@@ -853,19 +873,19 @@ export default function SettingsPanel({
853
873
 
854
874
  // Vertical roving focus over the tab rail (native-menu parity).
855
875
  function onRailKey(e) {
856
- const idx = TABS.findIndex((t) => t.id === tab);
876
+ const idx = tabs.findIndex((t) => t.id === tab);
857
877
  if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
858
878
  e.preventDefault();
859
- setTab(TABS[(idx + 1) % TABS.length].id);
879
+ setTab(tabs[(idx + 1) % tabs.length].id);
860
880
  } else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
861
881
  e.preventDefault();
862
- setTab(TABS[(idx - 1 + TABS.length) % TABS.length].id);
882
+ setTab(tabs[(idx - 1 + tabs.length) % tabs.length].id);
863
883
  } else if (e.key === 'Home') {
864
884
  e.preventDefault();
865
- setTab(TABS[0].id);
885
+ setTab(tabs[0].id);
866
886
  } else if (e.key === 'End') {
867
887
  e.preventDefault();
868
- setTab(TABS[TABS.length - 1].id);
888
+ setTab(tabs[tabs.length - 1].id);
869
889
  }
870
890
  }
871
891
 
@@ -896,7 +916,7 @@ export default function SettingsPanel({
896
916
  aria-label="Settings categories"
897
917
  onKeyDown={onRailKey}
898
918
  >
899
- {TABS.map((t) => (
919
+ {tabs.map((t) => (
900
920
  <button
901
921
  key={t.id}
902
922
  type="button"
@@ -14,11 +14,30 @@
14
14
 
15
15
  import React, { useCallback, useEffect, useRef, useState } from 'react';
16
16
 
17
- import { invoke, isNativeApp, openGitHubUrl } from './github.js';
17
+ import { invoke, isNativeApp, openGitHubUrl, pickMediaFile } from './github.js';
18
18
 
19
- const MAX_SHOTS = 3;
19
+ // Two of these are auto-captured (window + artboard), so the ceiling leaves
20
+ // room for the user to still attach their own.
21
+ const MAX_SHOTS = 4;
20
22
  const PUBLIC_ISSUES_URL = 'https://github.com/1aGh/maude/issues/new';
21
23
 
24
+ /** How each attached shot got here — shown per tile so the user can tell the
25
+ * auto-captured artboard (which never contains Maude's own chrome) apart from
26
+ * one they attached themselves. */
27
+ const SHOT_LABEL = {
28
+ shell: 'Maude window (auto)',
29
+ artboard: 'Active artboard (auto)',
30
+ attached: 'Attached by you',
31
+ };
32
+
33
+ /** The OS window-capture shortcut, for the "a Maude UI bug needs the chrome"
34
+ * hint. `platform` is the node `process.platform` string from the bundle. */
35
+ function windowShotHint(platform) {
36
+ if (platform === 'darwin') return '⇧⌘4 then Space';
37
+ if (platform === 'win32') return 'Win+Shift+S';
38
+ return 'your screenshot tool';
39
+ }
40
+
22
41
  function randHex(n) {
23
42
  const bytes = new Uint8Array(n / 2);
24
43
  crypto.getRandomValues(bytes);
@@ -124,6 +143,7 @@ function ShotTile({ shot, redacting, onRects, onRemove }) {
124
143
  />
125
144
  ))}
126
145
  </div>
146
+ <div className="rb-shot-src">{SHOT_LABEL[shot.source] ?? SHOT_LABEL.attached}</div>
127
147
  <div className="rb-shot-actions">
128
148
  {shot.rects.length > 0 && (
129
149
  <button type="button" className="rb-link" onClick={() => onRects([])}>
@@ -138,11 +158,11 @@ function ShotTile({ shot, redacting, onRects, onRemove }) {
138
158
  );
139
159
  }
140
160
 
141
- export function ReportBugDialog({ open, onClose }) {
161
+ export function ReportBugDialog({ open, onClose, activeCanvas = null }) {
142
162
  const [step, setStep] = useState('describe'); // describe | preview | sending | done | fallback
143
163
  const [description, setDescription] = useState('');
144
164
  const [bundle, setBundle] = useState(null);
145
- const [shots, setShots] = useState([]); // {url, blob, rects:[{x,y,w,h} normalized]}
165
+ const [shots, setShots] = useState([]); // {url, blob, source, rects:[{x,y,w,h} normalized]}
146
166
  const [crashLogs, setCrashLogs] = useState([]); // native only: {name, firstLine}
147
167
  const [consent, setConsent] = useState({
148
168
  logTail: true,
@@ -150,6 +170,7 @@ export function ReportBugDialog({ open, onClose }) {
150
170
  projectName: false,
151
171
  crashLogs: true,
152
172
  });
173
+ const [shellPending, setShellPending] = useState(false);
153
174
  const [redacting, setRedacting] = useState(false);
154
175
  const [result, setResult] = useState(null); // {issueUrl, issueNumber} | {fallbackDir}
155
176
  const [error, setError] = useState(null);
@@ -161,21 +182,52 @@ export function ReportBugDialog({ open, onClose }) {
161
182
  setBundle(null);
162
183
  setShots([]);
163
184
  setCrashLogs([]);
185
+ setShellPending(false);
164
186
  setRedacting(false);
165
187
  setResult(null);
166
188
  setError(null);
167
189
  // eslint-disable-next-line react-hooks/exhaustive-deps
168
190
  }, [shots]);
169
191
 
170
- const addShotBlob = useCallback((blob) => {
192
+ const addShotBlob = useCallback((blob, source = 'attached') => {
171
193
  if (!blob) return;
172
194
  setShots((prev) =>
173
195
  prev.length >= MAX_SHOTS
174
196
  ? prev
175
- : [...prev, { url: URL.createObjectURL(blob), blob, rects: [] }]
197
+ : [...prev, { url: URL.createObjectURL(blob), blob, source, rects: [] }]
176
198
  );
177
199
  }, []);
178
200
 
201
+ // Attach a screenshot the user took themselves — the ONLY lane that can carry
202
+ // Maude's own chrome (menubar, sidebar, dialogs), since the auto-capture
203
+ // renders the canvas headlessly and the shell is never in frame. Native
204
+ // WKWebView won't present a panel for an HTML <input type=file> at all, so it
205
+ // routes through the same Rust open-dialog the AssetPicker uses.
206
+ const attachScreenshot = useCallback(async () => {
207
+ if (isNativeApp()) {
208
+ try {
209
+ const picked = await pickMediaFile();
210
+ if (picked?.bytes) addShotBlob(new Blob([new Uint8Array(picked.bytes)]), 'attached');
211
+ } catch {
212
+ /* cancelled or dialog failure — paste (⌘V) still works */
213
+ }
214
+ return;
215
+ }
216
+ // Browser — imperative off-screen <input>, clicked inside the gesture
217
+ // (mirrors app.jsx's openFilePicker; display:none inputs don't lay out).
218
+ const input = document.createElement('input');
219
+ input.type = 'file';
220
+ input.accept = 'image/*';
221
+ input.style.cssText = 'position:fixed;left:-9999px;top:0';
222
+ document.body.appendChild(input);
223
+ input.addEventListener('change', () => {
224
+ const file = input.files?.[0];
225
+ if (file) addShotBlob(file, 'attached');
226
+ input.remove();
227
+ });
228
+ input.click();
229
+ }, [addShotBlob]);
230
+
179
231
  // On open: pull the scrubbed bundle, capture the active artboard, list
180
232
  // native crash logs. Every leg fails soft — a report with fewer attachments
181
233
  // beats no dialog.
@@ -186,14 +238,37 @@ export function ReportBugDialog({ open, onClose }) {
186
238
  .then((r) => (r.ok ? r.json() : null))
187
239
  .then((b) => alive && setBundle(b))
188
240
  .catch(() => {});
189
- fetch('/_api/export', {
241
+ // Artboard shot only when THIS window actually has a canvas open. The
242
+ // export resolves its target from the server's `_active.json`, so with
243
+ // nothing open it would attach a stale canvas from another session.
244
+ if (activeCanvas) {
245
+ fetch('/_api/export', {
246
+ method: 'POST',
247
+ headers: { 'Content-Type': 'application/json' },
248
+ body: JSON.stringify({ format: 'png', scope: 'artboard' }),
249
+ })
250
+ .then((r) => (r.ok ? r.blob() : null))
251
+ .then((blob) => alive && blob && addShotBlob(blob, 'artboard'))
252
+ .catch(() => {});
253
+ }
254
+ // The window shot — Maude's own chrome, which the artboard capture above
255
+ // structurally cannot see. Slow (boots a headless browser, opens the canvas),
256
+ // so it lands second and shows a pending row meanwhile. `canvas` is sent
257
+ // explicitly — including as null, which means "open nothing", so an empty
258
+ // window is reported as an empty window.
259
+ setShellPending(true);
260
+ fetch('/_api/shell-shot', {
190
261
  method: 'POST',
191
262
  headers: { 'Content-Type': 'application/json' },
192
- body: JSON.stringify({ format: 'png', scope: 'artboard' }),
263
+ body: JSON.stringify({ canvas: activeCanvas }),
193
264
  })
194
265
  .then((r) => (r.ok ? r.blob() : null))
195
- .then((blob) => alive && blob && addShotBlob(blob))
196
- .catch(() => {});
266
+ .then((blob) => {
267
+ if (!alive) return;
268
+ if (blob) addShotBlob(blob, 'shell');
269
+ setShellPending(false);
270
+ })
271
+ .catch(() => alive && setShellPending(false));
197
272
  if (isNativeApp()) {
198
273
  invoke('list_crash_logs')
199
274
  .then((logs) => alive && setCrashLogs(Array.isArray(logs) ? logs.slice(0, 3) : []))
@@ -203,7 +278,7 @@ export function ReportBugDialog({ open, onClose }) {
203
278
  alive = false;
204
279
  };
205
280
  // eslint-disable-next-line react-hooks/exhaustive-deps
206
- }, [open]);
281
+ }, [open, activeCanvas]);
207
282
 
208
283
  // Paste-to-attach — the lane that covers native-shell bugs the canvas
209
284
  // capture can't see (user screenshots the window, pastes here).
@@ -238,7 +313,9 @@ export function ReportBugDialog({ open, onClose }) {
238
313
  },
239
314
  context: {
240
315
  projectName: consent.projectName ? (bundle?.context?.projectName ?? null) : null,
241
- activeCanvas: consent.activeCanvas ? (bundle?.context?.activeCanvas ?? null) : null,
316
+ // This window's canvas, never the server's sticky `_active.json` — the
317
+ // report must not name a file the reporter does not have open.
318
+ activeCanvas: consent.activeCanvas ? activeCanvas : null,
242
319
  },
243
320
  description: description.trim(),
244
321
  attachments: {
@@ -385,21 +462,42 @@ export function ReportBugDialog({ open, onClose }) {
385
462
  <div className="rb-section">
386
463
  <div className="rb-section-hd">
387
464
  <span>Screenshots ({shots.length})</span>
388
- {shots.length > 0 && (
389
- <button
390
- type="button"
391
- className={`rb-link${redacting ? ' is-on' : ''}`}
392
- data-testid="report-bug-redact"
393
- onClick={() => setRedacting((v) => !v)}
394
- >
395
- {redacting ? 'Done redacting' : 'Redact…'}
396
- </button>
397
- )}
465
+ <span className="rb-hd-actions">
466
+ {shots.length < MAX_SHOTS && (
467
+ <button
468
+ type="button"
469
+ className="rb-link"
470
+ data-testid="report-bug-attach"
471
+ onClick={attachScreenshot}
472
+ >
473
+ Attach screenshot…
474
+ </button>
475
+ )}
476
+ {shots.length > 0 && (
477
+ <button
478
+ type="button"
479
+ className={`rb-link${redacting ? ' is-on' : ''}`}
480
+ data-testid="report-bug-redact"
481
+ onClick={() => setRedacting((v) => !v)}
482
+ >
483
+ {redacting ? 'Done redacting' : 'Redact…'}
484
+ </button>
485
+ )}
486
+ </span>
398
487
  </div>
399
- {shots.length === 0 ? (
400
- <p className="rb-hint">
401
- No screenshot attached. Paste one (⌘V) to add it — up to {MAX_SHOTS}.
488
+ <p className="rb-hint">
489
+ Maude captures its own window and the active artboard for you. The window shot is
490
+ a fresh render, so anything mid-flight — an open menu, a stuck spinner — won't be
491
+ in it: grab that yourself ({windowShotHint(bundle?.app?.platform)}) and attach or
492
+ paste (⌘V) it here.
493
+ </p>
494
+ {shellPending && (
495
+ <p className="rb-hint" data-testid="report-bug-shell-pending">
496
+ Capturing the Maude window…
402
497
  </p>
498
+ )}
499
+ {shots.length === 0 && !shellPending ? (
500
+ <p className="rb-hint">No screenshot attached yet — up to {MAX_SHOTS}.</p>
403
501
  ) : (
404
502
  <>
405
503
  {redacting && (
@@ -421,9 +519,6 @@ export function ReportBugDialog({ open, onClose }) {
421
519
  />
422
520
  ))}
423
521
  </div>
424
- {shots.length < MAX_SHOTS && (
425
- <p className="rb-hint">Paste (⌘V) to attach another screenshot.</p>
426
- )}
427
522
  </>
428
523
  )}
429
524
  </div>
@@ -439,7 +534,7 @@ export function ReportBugDialog({ open, onClose }) {
439
534
  {bundle?.app?.arch ?? ''} · {isNativeApp() ? 'desktop app' : 'browser'}
440
535
  </span>
441
536
  </label>
442
- {bundle?.context?.activeCanvas && (
537
+ {activeCanvas && (
443
538
  <label className="rb-check">
444
539
  <input
445
540
  type="checkbox"
@@ -447,7 +542,7 @@ export function ReportBugDialog({ open, onClose }) {
447
542
  onChange={(e) => setConsent({ ...consent, activeCanvas: e.target.checked })}
448
543
  />
449
544
  <span>
450
- Active canvas path — <code>{bundle.context.activeCanvas}</code>
545
+ Active canvas path — <code>{activeCanvas}</code>
451
546
  </span>
452
547
  </label>
453
548
  )}