@1agh/maude 0.36.0 → 0.37.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/apps/studio/canvas-shell.tsx +73 -2
- package/apps/studio/client/app.jsx +138 -10
- package/apps/studio/client/github.js +6 -0
- package/apps/studio/client/panels/CreateProject.jsx +57 -23
- package/apps/studio/client/panels/GitPanel.jsx +1 -1
- package/apps/studio/client/panels/IdentityBar.jsx +43 -2
- package/apps/studio/client/panels/OnboardingWizard.jsx +24 -21
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +4 -4
- package/apps/studio/client/tour/collab-tour.js +3 -3
- package/apps/studio/commands/edit-source-command.ts +118 -0
- package/apps/studio/dist/client.bundle.js +53617 -21
- package/apps/studio/github/endpoints.ts +42 -0
- package/apps/studio/http.ts +15 -0
- package/apps/studio/scaffold-design.ts +95 -2
- package/apps/studio/test/canvas-origin-gate.test.ts +3 -0
- package/apps/studio/test/edit-source-command.test.ts +119 -0
- package/apps/studio/test/sync-agent.test.ts +9 -2
- package/apps/studio/test/use-undo-stack.test.tsx +30 -0
- package/apps/studio/undo-stack.ts +10 -0
- package/apps/studio/use-undo-stack.tsx +21 -1
- package/apps/studio/whats-new.json +18 -0
- package/package.json +11 -9
|
@@ -107,6 +107,7 @@ export interface GitHubEndpoints {
|
|
|
107
107
|
repos(): Promise<GitHubEndpointResult>;
|
|
108
108
|
createRepo(body: unknown): Promise<GitHubEndpointResult>;
|
|
109
109
|
createProject(body: unknown): Promise<GitHubEndpointResult>;
|
|
110
|
+
createLocalProject(body: unknown): Promise<GitHubEndpointResult>;
|
|
110
111
|
invite(body: unknown): Promise<GitHubEndpointResult>;
|
|
111
112
|
clone(body: unknown): Promise<GitHubEndpointResult>;
|
|
112
113
|
initDesign(body: unknown): Promise<GitHubEndpointResult>;
|
|
@@ -346,6 +347,46 @@ export function createGitHubEndpoints(ctx: Context): GitHubEndpoints {
|
|
|
346
347
|
});
|
|
347
348
|
}
|
|
348
349
|
|
|
350
|
+
// "Just a local git repo, no GitHub remote" — same as createProject minus the
|
|
351
|
+
// network: mkdir + git init (defaultBranch main) + .design scaffold, NO token, NO
|
|
352
|
+
// createRepo, NO setRemote. The user can publish it later. Reuses the same slug +
|
|
353
|
+
// parent-dir validation as createProject, and the route is main-origin/loopback
|
|
354
|
+
// gated (http.ts) + absent from CANVAS_SAFE_API (dual-allowlist) like its sibling.
|
|
355
|
+
async function createLocalProject(body: unknown): Promise<GitHubEndpointResult> {
|
|
356
|
+
const b = (body ?? {}) as { name?: unknown; parentDir?: unknown };
|
|
357
|
+
const slug = slugifyRepoName(b.name);
|
|
358
|
+
if (!slug) return bad('Enter a project name (letters, numbers, hyphens).');
|
|
359
|
+
if (!validParentDir(b.parentDir))
|
|
360
|
+
return bad('Pick a folder on your computer to save the project in.');
|
|
361
|
+
const target = join(b.parentDir, slug);
|
|
362
|
+
if (existsSync(target)) {
|
|
363
|
+
return {
|
|
364
|
+
status: 409,
|
|
365
|
+
json: { ok: false, error: `A folder named “${slug}” already exists there.` },
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
const humanName = typeof b.name === 'string' ? b.name.trim() : slug;
|
|
369
|
+
try {
|
|
370
|
+
mkdirSync(target, { recursive: true });
|
|
371
|
+
await git.init({ fs, dir: target, defaultBranch: 'main' });
|
|
372
|
+
const s = scaffoldDesign(target, humanName);
|
|
373
|
+
if (!s.ok)
|
|
374
|
+
return {
|
|
375
|
+
status: 500,
|
|
376
|
+
json: { ok: false, error: s.error ?? 'Could not set up the project.' },
|
|
377
|
+
};
|
|
378
|
+
} catch (e) {
|
|
379
|
+
return {
|
|
380
|
+
status: 500,
|
|
381
|
+
json: {
|
|
382
|
+
ok: false,
|
|
383
|
+
error: e instanceof Error ? e.message : 'Could not set up the project.',
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
return { status: 200, json: { ok: true, path: target } };
|
|
388
|
+
}
|
|
389
|
+
|
|
349
390
|
// Fallback for "open a folder/repo that isn't a Maude project yet" — scaffold a
|
|
350
391
|
// bootable .design/ into an existing folder so it can open (no GitHub, no token).
|
|
351
392
|
async function initDesign(body: unknown): Promise<GitHubEndpointResult> {
|
|
@@ -368,6 +409,7 @@ export function createGitHubEndpoints(ctx: Context): GitHubEndpoints {
|
|
|
368
409
|
repos,
|
|
369
410
|
createRepo: createRepoHandler,
|
|
370
411
|
createProject,
|
|
412
|
+
createLocalProject,
|
|
371
413
|
invite,
|
|
372
414
|
clone,
|
|
373
415
|
initDesign,
|
package/apps/studio/http.ts
CHANGED
|
@@ -1050,6 +1050,21 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
1050
1050
|
return gitJson(await githubApi.createProject(body));
|
|
1051
1051
|
},
|
|
1052
1052
|
|
|
1053
|
+
// Create a NEW *local-only* project: mkdir + git init + .design scaffold, NO
|
|
1054
|
+
// GitHub / no token (the "just local git, no remote" path). Same main-origin +
|
|
1055
|
+
// loopback + POST CSRF gate as create-project, and MAIN-ORIGIN ONLY — absent
|
|
1056
|
+
// from CANVAS_SAFE_API + startCanvasServer routes (dual-allowlist), so the
|
|
1057
|
+
// untrusted canvas iframe can't drive disk writes.
|
|
1058
|
+
'/_api/project/create-local': async (req: Request) => {
|
|
1059
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1060
|
+
if (!sameOriginWrite(req))
|
|
1061
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1062
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1063
|
+
return new Response('local request required', { status: 403 });
|
|
1064
|
+
const body = await readJson<unknown>(req, 8 * 1024);
|
|
1065
|
+
return gitJson(await githubApi.createLocalProject(body));
|
|
1066
|
+
},
|
|
1067
|
+
|
|
1053
1068
|
// Scaffold a bootable .design/ into an existing folder (the "open a non-Maude
|
|
1054
1069
|
// repo → set it up?" fallback). No token / no GitHub — local FS only, but still
|
|
1055
1070
|
// main-origin + loopback gated (it writes to disk).
|
|
@@ -13,6 +13,93 @@ import { basename, join } from 'node:path';
|
|
|
13
13
|
const CONFIG_SCHEMA =
|
|
14
14
|
'https://raw.githubusercontent.com/1aGh/maude/main/apps/studio/config.schema.json';
|
|
15
15
|
|
|
16
|
+
// A neutral, token-free starter canvas seeded into `ui/` so a freshly-created project
|
|
17
|
+
// opens to a real artboard instead of an empty studio (the "empty-studio after Start a
|
|
18
|
+
// new project" onboarding gap). It depends ONLY on @maude/canvas-lib + inline styles —
|
|
19
|
+
// no design-system tokens — so it renders before /design:setup-ds exists. The button
|
|
20
|
+
// vocabulary mirrors the shipped GitPanel (Save version · Publish · Get latest).
|
|
21
|
+
const STARTER_CANVAS_TSX = `/**
|
|
22
|
+
* @canvas welcome · Your first canvas — seeded when a new Maude project is created.
|
|
23
|
+
* @platform desktop
|
|
24
|
+
* @stack React 19 · TSX · Bun.build
|
|
25
|
+
*
|
|
26
|
+
* Neutral + token-free so it renders the moment the project is created — before
|
|
27
|
+
* /design:setup-ds builds a design system. Edit it, replace it, or delete it once you
|
|
28
|
+
* have your own canvases.
|
|
29
|
+
*/
|
|
30
|
+
import { DCArtboard, DCSection, DesignCanvas } from "@maude/canvas-lib";
|
|
31
|
+
|
|
32
|
+
export default function Welcome() {
|
|
33
|
+
return (
|
|
34
|
+
<DesignCanvas>
|
|
35
|
+
<DCSection id="welcome" title="Welcome to Maude" subtitle="START HERE">
|
|
36
|
+
<DCArtboard id="welcome" label="WELCOME/01" width={680} height={440}>
|
|
37
|
+
<div
|
|
38
|
+
data-testid="welcome-artboard-content"
|
|
39
|
+
style={{
|
|
40
|
+
height: "100%",
|
|
41
|
+
boxSizing: "border-box",
|
|
42
|
+
padding: 48,
|
|
43
|
+
display: "flex",
|
|
44
|
+
flexDirection: "column",
|
|
45
|
+
gap: 24,
|
|
46
|
+
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
47
|
+
color: "#16181d",
|
|
48
|
+
background: "#fbfbfc",
|
|
49
|
+
}}
|
|
50
|
+
>
|
|
51
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
52
|
+
<span style={{ fontSize: 12, letterSpacing: "0.12em", textTransform: "uppercase", color: "#8a8f98" }}>
|
|
53
|
+
Your first canvas
|
|
54
|
+
</span>
|
|
55
|
+
<h1 style={{ margin: 0, fontSize: 30, lineHeight: 1.1, fontWeight: 650 }}>
|
|
56
|
+
You're in. This is a canvas.
|
|
57
|
+
</h1>
|
|
58
|
+
<p style={{ margin: 0, fontSize: 15, lineHeight: 1.5, color: "#4a4f57", maxWidth: 460 }}>
|
|
59
|
+
Everything you design lives on canvases like this one. Edit it, or start fresh —
|
|
60
|
+
your work saves on its own, and you can version and share it without ever opening a terminal.
|
|
61
|
+
</p>
|
|
62
|
+
</div>
|
|
63
|
+
<div style={{ display: "flex", gap: 12 }}>
|
|
64
|
+
{[
|
|
65
|
+
["Save version", "keep a checkpoint, just for you"],
|
|
66
|
+
["Publish", "share it with your team"],
|
|
67
|
+
["Get latest", "pull in everyone else's work"],
|
|
68
|
+
].map(([title, desc]) => (
|
|
69
|
+
<div key={title} style={{ flex: 1, padding: 16, border: "1px solid #e6e7ea", borderRadius: 10, background: "#fff" }}>
|
|
70
|
+
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 4 }}>{title}</div>
|
|
71
|
+
<div style={{ fontSize: 12.5, lineHeight: 1.4, color: "#6b7078" }}>{desc}</div>
|
|
72
|
+
</div>
|
|
73
|
+
))}
|
|
74
|
+
</div>
|
|
75
|
+
<p style={{ margin: 0, fontSize: 13, lineHeight: 1.5, color: "#8a8f98" }}>
|
|
76
|
+
Next: build a design system, or just start editing this canvas.
|
|
77
|
+
</p>
|
|
78
|
+
</div>
|
|
79
|
+
</DCArtboard>
|
|
80
|
+
</DCSection>
|
|
81
|
+
</DesignCanvas>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
`;
|
|
85
|
+
|
|
86
|
+
const STARTER_CANVAS_META = {
|
|
87
|
+
title: 'Welcome',
|
|
88
|
+
subtitle: 'Your first canvas — seeded on project creation',
|
|
89
|
+
brief:
|
|
90
|
+
'Neutral, token-free welcome canvas written by scaffoldDesign() so a new project opens to a real artboard instead of an empty studio. Safe to edit or delete.',
|
|
91
|
+
platform: 'desktop',
|
|
92
|
+
sections: [
|
|
93
|
+
{
|
|
94
|
+
id: 'welcome',
|
|
95
|
+
title: 'Welcome to Maude · START HERE',
|
|
96
|
+
artboards: [{ id: 'welcome', label: 'WELCOME/01', width: 680, height: 440 }],
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
css_mode: 'inline',
|
|
100
|
+
layout: { artboards: [{ id: 'welcome', x: 0, y: 0 }] },
|
|
101
|
+
};
|
|
102
|
+
|
|
16
103
|
/** Whether `dir` is already a Maude project (has `.design/config.json`). */
|
|
17
104
|
export function hasDesign(dir: string): boolean {
|
|
18
105
|
return existsSync(join(dir, '.design', 'config.json'));
|
|
@@ -47,8 +134,14 @@ export function scaffoldDesign(dir: string, name?: string): ScaffoldResult {
|
|
|
47
134
|
completenessProfile: 'standard',
|
|
48
135
|
};
|
|
49
136
|
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
|
50
|
-
//
|
|
51
|
-
writeFileSync(join(designDir, 'ui', '.
|
|
137
|
+
// Seed a starter canvas so the studio opens to a real artboard, not an empty list.
|
|
138
|
+
writeFileSync(join(designDir, 'ui', 'Welcome.tsx'), STARTER_CANVAS_TSX, 'utf8');
|
|
139
|
+
writeFileSync(
|
|
140
|
+
join(designDir, 'ui', 'Welcome.meta.json'),
|
|
141
|
+
`${JSON.stringify(STARTER_CANVAS_META, null, 2)}\n`,
|
|
142
|
+
'utf8'
|
|
143
|
+
);
|
|
144
|
+
// `system/` is genuinely empty until /design:setup-ds — keep it in git.
|
|
52
145
|
writeFileSync(join(designDir, 'system', '.gitkeep'), '', 'utf8');
|
|
53
146
|
return { ok: true };
|
|
54
147
|
} catch (e) {
|
|
@@ -99,6 +99,9 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
|
|
|
99
99
|
'/_api/github/invite',
|
|
100
100
|
'/_api/github/clone',
|
|
101
101
|
'/_api/github/create-project',
|
|
102
|
+
// Local-only project create (mkdir + git init + scaffold) — writes to disk,
|
|
103
|
+
// no token; MAIN-ORIGIN ONLY, so the canvas origin must 403 at the gate.
|
|
104
|
+
'/_api/project/create-local',
|
|
102
105
|
'/_api/design/init',
|
|
103
106
|
// Phase 29 (E4) Door C — the hub-link credential write is MAIN-ORIGIN ONLY;
|
|
104
107
|
// the untrusted canvas origin must never reach it (it writes the global
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
buildEditSourceRecord,
|
|
5
|
+
createEditSourceCommand,
|
|
6
|
+
EDIT_SOURCE_KIND,
|
|
7
|
+
type EditSourceApplyFn,
|
|
8
|
+
type EditSourcePayload,
|
|
9
|
+
} from '../commands/edit-source-command.ts';
|
|
10
|
+
import { type CommandSinks, rebuildCommand, registerCommand } from '../undo-stack.ts';
|
|
11
|
+
|
|
12
|
+
const cssEdit: EditSourcePayload = {
|
|
13
|
+
op: 'css',
|
|
14
|
+
canvas: '.design/ui/Foo.tsx',
|
|
15
|
+
id: 'cd-1',
|
|
16
|
+
key: 'color',
|
|
17
|
+
before: 'red',
|
|
18
|
+
after: 'blue',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe('createEditSourceCommand', () => {
|
|
22
|
+
test('do() applies the AFTER value', async () => {
|
|
23
|
+
const applyFn = mock(() => {});
|
|
24
|
+
const cmd = createEditSourceCommand({ payload: cssEdit, applyFn });
|
|
25
|
+
await cmd.do();
|
|
26
|
+
expect(applyFn).toHaveBeenCalledTimes(1);
|
|
27
|
+
expect(applyFn.mock.calls[0]?.[0]).toEqual({
|
|
28
|
+
op: 'css',
|
|
29
|
+
canvas: '.design/ui/Foo.tsx',
|
|
30
|
+
id: 'cd-1',
|
|
31
|
+
key: 'color',
|
|
32
|
+
value: 'blue',
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('undo() applies the BEFORE value', async () => {
|
|
37
|
+
const applyFn = mock(() => {});
|
|
38
|
+
const cmd = createEditSourceCommand({ payload: cssEdit, applyFn });
|
|
39
|
+
await cmd.undo();
|
|
40
|
+
expect(applyFn.mock.calls[0]?.[0]?.value).toBe('red');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('undo() of a NEW prop (before:null) applies null = reset', async () => {
|
|
44
|
+
const applyFn = mock(() => {});
|
|
45
|
+
const cmd = createEditSourceCommand({
|
|
46
|
+
payload: { ...cssEdit, before: null, after: 'blue' },
|
|
47
|
+
applyFn,
|
|
48
|
+
});
|
|
49
|
+
await cmd.undo();
|
|
50
|
+
expect(applyFn.mock.calls[0]?.[0]?.value).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('do() of a RESET (after:null) applies null = remove the prop', async () => {
|
|
54
|
+
const applyFn = mock(() => {});
|
|
55
|
+
const cmd = createEditSourceCommand({
|
|
56
|
+
payload: { ...cssEdit, before: 'red', after: null },
|
|
57
|
+
applyFn,
|
|
58
|
+
});
|
|
59
|
+
await cmd.do();
|
|
60
|
+
expect(applyFn.mock.calls[0]?.[0]?.value).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('label reflects op + key', () => {
|
|
64
|
+
expect(createEditSourceCommand({ payload: cssEdit, applyFn: () => {} }).label).toBe(
|
|
65
|
+
'edit color'
|
|
66
|
+
);
|
|
67
|
+
expect(
|
|
68
|
+
createEditSourceCommand({ payload: { ...cssEdit, after: null }, applyFn: () => {} }).label
|
|
69
|
+
).toBe('reset color');
|
|
70
|
+
expect(
|
|
71
|
+
createEditSourceCommand({
|
|
72
|
+
payload: { op: 'attr', canvas: 'x', id: 'y', key: 'data-x', before: null, after: '1' },
|
|
73
|
+
applyFn: () => {},
|
|
74
|
+
}).label
|
|
75
|
+
).toBe('edit @data-x');
|
|
76
|
+
expect(
|
|
77
|
+
createEditSourceCommand({
|
|
78
|
+
payload: { op: 'text', canvas: 'x', id: 'y', key: '', before: 'a', after: 'b' },
|
|
79
|
+
applyFn: () => {},
|
|
80
|
+
}).label
|
|
81
|
+
).toBe('edit text');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('buildEditSourceRecord', () => {
|
|
86
|
+
test('produces a serializable record under the edit-source kind', () => {
|
|
87
|
+
const rec = buildEditSourceRecord(cssEdit);
|
|
88
|
+
expect(rec.kind).toBe(EDIT_SOURCE_KIND);
|
|
89
|
+
expect(rec.payload).toEqual(cssEdit);
|
|
90
|
+
expect(JSON.parse(JSON.stringify(rec))).toEqual(rec); // round-trips
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('edit-source registry', () => {
|
|
95
|
+
// The module self-registers on import, but sibling test files wipe the shared
|
|
96
|
+
// builder registry in their beforeEach — re-register here so this block is
|
|
97
|
+
// order-independent. Mirrors the real registration in edit-source-command.ts.
|
|
98
|
+
beforeAll(() => {
|
|
99
|
+
registerCommand<EditSourcePayload>(EDIT_SOURCE_KIND, (record, sinks) => {
|
|
100
|
+
const applyFn = sinks.editSourceApplyFn as EditSourceApplyFn | undefined;
|
|
101
|
+
if (!applyFn) return null;
|
|
102
|
+
return createEditSourceCommand({ payload: record.payload, applyFn, label: record.label });
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('rebuildCommand returns null when the editSourceApplyFn sink is unbound', () => {
|
|
107
|
+
expect(rebuildCommand(buildEditSourceRecord(cssEdit), {} as CommandSinks)).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('rebuildCommand wires the bound sink into a runnable command', async () => {
|
|
111
|
+
const applyFn = mock(() => {});
|
|
112
|
+
const cmd = rebuildCommand(buildEditSourceRecord(cssEdit), {
|
|
113
|
+
editSourceApplyFn: applyFn,
|
|
114
|
+
} as CommandSinks);
|
|
115
|
+
expect(cmd).not.toBeNull();
|
|
116
|
+
await cmd?.do();
|
|
117
|
+
expect(applyFn.mock.calls[0]?.[0]?.value).toBe('blue');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -190,14 +190,21 @@ describe('CanvasSyncAgent — cold start reconciliation', () => {
|
|
|
190
190
|
agent = makeAgent({ adopt: true });
|
|
191
191
|
await agent.reconcile();
|
|
192
192
|
|
|
193
|
-
// Hub changes the value
|
|
193
|
+
// Hub changes the value — stamp it explicitly newer than the disk so the
|
|
194
|
+
// newest-wins reconcile is deterministic. An un-pinned `applyHtmlToDoc` stamp
|
|
195
|
+
// is `Date.now()` (ms-truncated) while the disk mtime is sub-ms `statSync`:
|
|
196
|
+
// in isolation they land in the same ms → tie → hub; under parallel-suite
|
|
197
|
+
// load they straddle a ms boundary → disk looks newer → local, flaking this
|
|
198
|
+
// assertion. Sibling tests pin timestamps for exactly this reason.
|
|
194
199
|
applyHtmlToDoc(docA, '<button>hub-v2</button>');
|
|
200
|
+
docA.getMap('syncMeta').set('bodyEditAt', Date.now() + 60_000);
|
|
195
201
|
|
|
196
202
|
// Disk reverts to a stale local-only state.
|
|
197
203
|
writeFileSync(paths().html, '<button>local-v3</button>');
|
|
198
204
|
await agent.reconcile();
|
|
199
205
|
|
|
200
|
-
// Hub state won this time
|
|
206
|
+
// Hub state won this time — adopt was consumed, so the second reconcile runs
|
|
207
|
+
// normal newest-wins, and the (explicitly newer) hub body wins.
|
|
201
208
|
expect(htmlFromDoc(docB)).toBe('<button>hub-v2</button>');
|
|
202
209
|
});
|
|
203
210
|
|
|
@@ -155,6 +155,36 @@ describe('use-undo-stack / runner side-effects', () => {
|
|
|
155
155
|
});
|
|
156
156
|
});
|
|
157
157
|
|
|
158
|
+
describe('use-undo-stack / record (already-applied edits)', () => {
|
|
159
|
+
test('record() appends WITHOUT running do()', async () => {
|
|
160
|
+
const v = captureProvider(undefined, { layoutPatchFn: () => {} });
|
|
161
|
+
v.record(rec('inline-edit', 'X'));
|
|
162
|
+
// undo() is enqueued after record(), so awaiting it flushes record first.
|
|
163
|
+
await v.undo();
|
|
164
|
+
expect(doSpy).toHaveBeenCalledTimes(0); // record never runs do()
|
|
165
|
+
expect(undoSpy).toHaveBeenCalledTimes(1);
|
|
166
|
+
expect(undoSpy.mock.calls[0]?.[0]).toBe('X');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('record() then redo() (after undo) re-runs do()', async () => {
|
|
170
|
+
const v = captureProvider(undefined, { layoutPatchFn: () => {} });
|
|
171
|
+
v.record(rec('e', 'Y'));
|
|
172
|
+
await v.undo();
|
|
173
|
+
await v.redo();
|
|
174
|
+
expect(doSpy).toHaveBeenCalledTimes(1); // only redo's do(), not the record
|
|
175
|
+
expect(doSpy.mock.calls[0]?.[0]).toBe('Y');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('record() clears the redo future', async () => {
|
|
179
|
+
const v = captureProvider(undefined, { layoutPatchFn: () => {} });
|
|
180
|
+
await v.push(rec('a', 'A')); // doSpy: 1
|
|
181
|
+
await v.undo(); // future = [a]
|
|
182
|
+
v.record(rec('b', 'B')); // clears future
|
|
183
|
+
await v.redo(); // future empty → no-op, no extra do()
|
|
184
|
+
expect(doSpy).toHaveBeenCalledTimes(1);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
158
188
|
describe('use-undo-stack / cross-canvas persistence', () => {
|
|
159
189
|
test('history under a canvasFile survives a fresh provider mount with the same canvasFile', async () => {
|
|
160
190
|
// First mount: push 2 records.
|
|
@@ -71,6 +71,16 @@ export interface CommandSinks {
|
|
|
71
71
|
layoutPatchFn?: unknown;
|
|
72
72
|
/** Wired by `AnnotationsLayer`. */
|
|
73
73
|
strokesPutFn?: unknown;
|
|
74
|
+
/**
|
|
75
|
+
* Wired by `CanvasShell` (canvas-shell.tsx). Applies a single inline source
|
|
76
|
+
* edit (CSS / text / attr) by posting `dgn:'apply-edit'` to the parent shell,
|
|
77
|
+
* which owns the main-origin-only `/_api/edit-*` write (DDR-054 — the canvas
|
|
78
|
+
* iframe can't call those routes directly). Drives undo/redo of inspector
|
|
79
|
+
* CSS edits, inline text rewrites, and custom-attribute edits, which write
|
|
80
|
+
* the canvas `.tsx` source and were previously unrecoverable (no command, no
|
|
81
|
+
* snapshot). See `commands/edit-source-command.ts`.
|
|
82
|
+
*/
|
|
83
|
+
editSourceApplyFn?: unknown;
|
|
74
84
|
}
|
|
75
85
|
|
|
76
86
|
export type CommandBuilder<P = unknown> = (
|
|
@@ -51,6 +51,14 @@ export interface UndoStackValue {
|
|
|
51
51
|
* Awaiting the returned promise is optional.
|
|
52
52
|
*/
|
|
53
53
|
push: (record: CommandRecord) => Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* Record an ALREADY-APPLIED command — appends to `past` + clears `future`
|
|
56
|
+
* WITHOUT running `do()`. Use when the side-effect happened outside the
|
|
57
|
+
* stack (e.g. an inspector CSS commit / inline text edit that already POSTed
|
|
58
|
+
* `/_api/edit-*`), so re-running `do()` would double-apply. `undo()` / `redo()`
|
|
59
|
+
* still drive the command's `undo()` / `do()` normally afterwards.
|
|
60
|
+
*/
|
|
61
|
+
record: (record: CommandRecord) => void;
|
|
54
62
|
/** Undo the top of `past`. No-op when empty. */
|
|
55
63
|
undo: () => Promise<void>;
|
|
56
64
|
/** Redo the top of `future`. No-op when empty. */
|
|
@@ -69,6 +77,7 @@ const UndoStackContext = createContext<UndoStackValue | null>(null);
|
|
|
69
77
|
|
|
70
78
|
const NOOP_VALUE: UndoStackValue = {
|
|
71
79
|
push: () => Promise.resolve(),
|
|
80
|
+
record: () => {},
|
|
72
81
|
undo: () => Promise.resolve(),
|
|
73
82
|
redo: () => Promise.resolve(),
|
|
74
83
|
clear: () => {},
|
|
@@ -213,6 +222,16 @@ export function UndoStackProvider({
|
|
|
213
222
|
[enqueue, build, reportError, bumpLabel, writeState]
|
|
214
223
|
);
|
|
215
224
|
|
|
225
|
+
const record = useCallback(
|
|
226
|
+
(rec: CommandRecord): void => {
|
|
227
|
+
void enqueue(async () => {
|
|
228
|
+
writeState(undoReducer(stateRef.current, { type: 'push', record: rec }));
|
|
229
|
+
bumpLabel(rec.label);
|
|
230
|
+
});
|
|
231
|
+
},
|
|
232
|
+
[enqueue, writeState, bumpLabel]
|
|
233
|
+
);
|
|
234
|
+
|
|
216
235
|
const undo = useCallback(
|
|
217
236
|
(): Promise<void> =>
|
|
218
237
|
enqueue(async () => {
|
|
@@ -306,6 +325,7 @@ export function UndoStackProvider({
|
|
|
306
325
|
const value = useMemo<UndoStackValue>(
|
|
307
326
|
() => ({
|
|
308
327
|
push,
|
|
328
|
+
record,
|
|
309
329
|
undo,
|
|
310
330
|
redo,
|
|
311
331
|
clear,
|
|
@@ -318,7 +338,7 @@ export function UndoStackProvider({
|
|
|
318
338
|
// `lastTick` (bumped by every push/undo/redo/clear) as the proxy so the
|
|
319
339
|
// memoized canUndo/canRedo readouts re-evaluate on each transition.
|
|
320
340
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
321
|
-
[push, undo, redo, clear, lastLabel, lastTick]
|
|
341
|
+
[push, record, undo, redo, clear, lastLabel, lastTick]
|
|
322
342
|
);
|
|
323
343
|
|
|
324
344
|
return (
|
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./whats-new.schema.json",
|
|
3
3
|
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"id": "new-project-menu-and-local",
|
|
6
|
+
"version": "0.37.0",
|
|
7
|
+
"date": "2026-07-01",
|
|
8
|
+
"kind": "feature",
|
|
9
|
+
"title": "Start a project from the menu — or fully local",
|
|
10
|
+
"summary": "New Project… is now in the File menu (⌘N), not just onboarding. And you can create a project on This computer only — a plain local git repo with no GitHub and no remote, that you can publish later. It works even when you're signed out.",
|
|
11
|
+
"surface": "design-ui"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "desktop-onboarding-fixes",
|
|
15
|
+
"version": "0.36.2",
|
|
16
|
+
"date": "2026-06-30",
|
|
17
|
+
"kind": "fix",
|
|
18
|
+
"title": "A smoother first run",
|
|
19
|
+
"summary": "Onboarding got a polish. If you're already signed in, the GitHub step now says Continue instead of asking you to sign in again, and your signed-in status shows reliably. Cancelling the sign-in code screen frees the button right away, “Merge this branch → main” shows progress while it runs, and a brand-new project opens to a starter canvas instead of an empty workspace.",
|
|
20
|
+
"surface": "design-ui"
|
|
21
|
+
},
|
|
4
22
|
{
|
|
5
23
|
"id": "git-native-switcher",
|
|
6
24
|
"version": "0.36.0",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@1agh/maude",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.0",
|
|
4
4
|
"description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"dev": "bun run apps/studio/server.ts --port 4555",
|
|
22
22
|
"dev:site": "pnpm --filter @maude/site dev --port 4398",
|
|
23
23
|
"dev:desktop": "PATH=\"$HOME/.cargo/bin:$PATH\" pnpm --filter @maude/desktop tauri dev",
|
|
24
|
+
"dev:desktop:onboarding": "PATH=\"$HOME/.cargo/bin:$PATH\" MAUDE_FORCE_ONBOARDING=1 pnpm --filter @maude/desktop tauri dev",
|
|
24
25
|
"build:desktop": "PATH=\"$HOME/.cargo/bin:$PATH\" pnpm --filter @maude/desktop tauri build",
|
|
25
26
|
"test:e2e:desktop:build": "PATH=\"$HOME/.cargo/bin:$PATH\" pnpm --filter @maude/desktop tauri build --debug --config src-tauri/tauri.e2e.conf.json",
|
|
26
27
|
"test:e2e:desktop": "pnpm --filter @maude/desktop-e2e e2e",
|
|
@@ -45,16 +46,17 @@
|
|
|
45
46
|
"postinstall": "node cli/install.cjs",
|
|
46
47
|
"prepublishOnly": "bash scripts/check-version-parity.sh && bash apps/studio/bin/check-runtime-bundles.sh",
|
|
47
48
|
"test:e2e:desktop:lifecycle": "pnpm --filter @maude/desktop-e2e e2e:lifecycle",
|
|
48
|
-
"test:e2e:desktop:switchrepos": "pnpm --filter @maude/desktop-e2e e2e:switchrepos"
|
|
49
|
+
"test:e2e:desktop:switchrepos": "pnpm --filter @maude/desktop-e2e e2e:switchrepos",
|
|
50
|
+
"test:e2e:desktop:onboarding": "pnpm --filter @maude/desktop-e2e e2e:onboarding"
|
|
49
51
|
},
|
|
50
52
|
"optionalDependencies": {
|
|
51
|
-
"@1agh/maude-darwin-arm64": "0.
|
|
52
|
-
"@1agh/maude-darwin-x64": "0.
|
|
53
|
-
"@1agh/maude-linux-arm64": "0.
|
|
54
|
-
"@1agh/maude-linux-arm64-musl": "0.
|
|
55
|
-
"@1agh/maude-linux-x64": "0.
|
|
56
|
-
"@1agh/maude-linux-x64-musl": "0.
|
|
57
|
-
"@1agh/maude-win32-x64": "0.
|
|
53
|
+
"@1agh/maude-darwin-arm64": "0.37.0",
|
|
54
|
+
"@1agh/maude-darwin-x64": "0.37.0",
|
|
55
|
+
"@1agh/maude-linux-arm64": "0.37.0",
|
|
56
|
+
"@1agh/maude-linux-arm64-musl": "0.37.0",
|
|
57
|
+
"@1agh/maude-linux-x64": "0.37.0",
|
|
58
|
+
"@1agh/maude-linux-x64-musl": "0.37.0",
|
|
59
|
+
"@1agh/maude-win32-x64": "0.37.0"
|
|
58
60
|
},
|
|
59
61
|
"files": [
|
|
60
62
|
"cli",
|