@1agh/maude 0.57.0 → 0.58.1
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/README.md +5 -1
- package/apps/studio/api.ts +69 -4
- package/apps/studio/artboard-hug-commit.ts +30 -0
- package/apps/studio/client/app.jsx +162 -20
- package/apps/studio/client/panels/chat-markdown.jsx +76 -5
- package/apps/studio/client/panels/file-preview.jsx +115 -0
- package/apps/studio/client/panels/timeline-media-cache.js +38 -2
- package/apps/studio/client/styles/3-shell-maude.css +45 -0
- package/apps/studio/client/styles/6-acp-chat.css +28 -0
- package/apps/studio/clip-ops.ts +84 -35
- package/apps/studio/dist/client.bundle.js +1585 -1585
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +8 -0
- package/apps/studio/input-router.tsx +39 -1
- package/apps/studio/test/artboard-hug-commit.test.ts +36 -0
- package/apps/studio/test/clip-ops.test.ts +84 -2
- package/apps/studio/test/config-version.test.ts +88 -0
- package/apps/studio/test/input-router.test.ts +56 -0
- package/apps/studio/test/timeline-media-cache.test.ts +41 -0
- package/apps/studio/test/whats-new.test.ts +13 -1
- package/apps/studio/whats-new.json +9 -0
- package/apps/studio/whats-new.ts +34 -10
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -175,7 +175,11 @@ pnpm version # = bash scripts/changesets-version.sh
|
|
|
175
175
|
# → re-runs scripts/check-version-parity.sh
|
|
176
176
|
|
|
177
177
|
git commit -am "chore: release v$(node -p "require('./package.json').version")"
|
|
178
|
-
|
|
178
|
+
# ANNOTATED (-a -m) is required — `git push --follow-tags` only pushes annotated
|
|
179
|
+
# tags. A lightweight `git tag vX.Y.Z` will silently stay local and no release
|
|
180
|
+
# workflow ever fires.
|
|
181
|
+
VER="v$(node -p "require('./package.json').version")"
|
|
182
|
+
git tag -a "$VER" -m "$VER"
|
|
179
183
|
git push --follow-tags
|
|
180
184
|
```
|
|
181
185
|
|
package/apps/studio/api.ts
CHANGED
|
@@ -117,6 +117,33 @@ export const SKIP_DIRS = new Set([
|
|
|
117
117
|
]);
|
|
118
118
|
const HIDDEN_OK = new Set(['.ai', '.claude', '.design']);
|
|
119
119
|
|
|
120
|
+
// feature-studio-file-preview — binary/media extensions the tree lists so a
|
|
121
|
+
// DS's assets/{fonts,graphics,logos,photos,...} files show up (previously
|
|
122
|
+
// only their parent folders did, via findFiles's dirsOut accounting). Kept as
|
|
123
|
+
// an explicit enumerated list rather than a broad pattern so it can never
|
|
124
|
+
// accidentally widen to swallow runtime JSON — findFiles already excludes
|
|
125
|
+
// `_`-prefixed entries before this list is even consulted.
|
|
126
|
+
const PREVIEW_ASSET_EXTS = [
|
|
127
|
+
'.svg',
|
|
128
|
+
'.png',
|
|
129
|
+
'.jpg',
|
|
130
|
+
'.jpeg',
|
|
131
|
+
'.gif',
|
|
132
|
+
'.webp',
|
|
133
|
+
'.avif',
|
|
134
|
+
'.woff',
|
|
135
|
+
'.woff2',
|
|
136
|
+
'.ttf',
|
|
137
|
+
'.otf',
|
|
138
|
+
'.mp4',
|
|
139
|
+
'.webm',
|
|
140
|
+
'.mov',
|
|
141
|
+
'.mp3',
|
|
142
|
+
'.wav',
|
|
143
|
+
'.ogg',
|
|
144
|
+
'.m4a',
|
|
145
|
+
];
|
|
146
|
+
|
|
120
147
|
// ---------- File tree ----------
|
|
121
148
|
|
|
122
149
|
/**
|
|
@@ -181,7 +208,31 @@ async function findFiles(
|
|
|
181
208
|
if (e.isDirectory()) {
|
|
182
209
|
dirsOut?.push(rel);
|
|
183
210
|
out.push(...(await findFiles(full, rel, exts, dirsOut)));
|
|
184
|
-
} else if (exts.some((x) => e.name.toLowerCase().endsWith(x)))
|
|
211
|
+
} else if (e.isFile() && exts.some((x) => e.name.toLowerCase().endsWith(x))) {
|
|
212
|
+
// feature-studio-file-preview security review — `e.isFile()` (not just
|
|
213
|
+
// "not a directory") excludes symlinks: a symlink Dirent is neither
|
|
214
|
+
// isDirectory() nor isFile(), so without this check a symlink dropped
|
|
215
|
+
// into an assets/ folder (e.g. pointing at ~/.ssh/id_rsa, named to fit
|
|
216
|
+
// an allowlisted extension) would be listed and, since this feature
|
|
217
|
+
// makes every listed row one-click-fetchable, served straight into the
|
|
218
|
+
// preview panel.
|
|
219
|
+
//
|
|
220
|
+
// A HARDLINK survives `isFile()` (hardlinks are, by design, ordinary
|
|
221
|
+
// files — same inode, indistinguishable from the "original" at the
|
|
222
|
+
// Dirent level), so it needs a second check: `nlink > 1` means this
|
|
223
|
+
// directory entry shares its inode with at least one other name
|
|
224
|
+
// somewhere on the filesystem. A design system's own assets are never
|
|
225
|
+
// legitimately multiply-linked, so excluding them closes the same
|
|
226
|
+
// one-click-disclosure path for a hardlink planted at, say, a
|
|
227
|
+
// teammate's readable dotfile.
|
|
228
|
+
try {
|
|
229
|
+
const st = await lstat(full);
|
|
230
|
+
if (st.nlink > 1) continue;
|
|
231
|
+
} catch {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
out.push(rel);
|
|
235
|
+
}
|
|
185
236
|
}
|
|
186
237
|
return out;
|
|
187
238
|
}
|
|
@@ -5268,15 +5319,29 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
5268
5319
|
const isDs = g.label === 'Design system' || /^system(\/|$)/.test(g.path);
|
|
5269
5320
|
// Always include canvas sidecars (`.meta.json`, `.css`, `.registry.json`)
|
|
5270
5321
|
// so the client can nest them under their primary `.tsx` / `.html`. DS
|
|
5271
|
-
// groups additionally surface `.md` for README + SKILL docs
|
|
5322
|
+
// groups additionally surface `.md` for README + SKILL docs, plus
|
|
5323
|
+
// PREVIEW_ASSET_EXTS (images/fonts/video/audio) so assets/ subfolders
|
|
5324
|
+
// list their files instead of rendering as permanently-empty tree nodes
|
|
5325
|
+
// (feature-studio-file-preview) — click-time behavior lives client-side
|
|
5326
|
+
// in FileRow's previewKind() branch.
|
|
5272
5327
|
// feature-file-tree-drag-drop-folders (Task 6) — `dirs` accumulates
|
|
5273
5328
|
// every directory in this group (incl. empty ones) via the SAME
|
|
5274
5329
|
// traversal, so a freshly `mkdir`'d folder with no files yet is still
|
|
5275
5330
|
// representable in the tree.
|
|
5276
5331
|
const dirs: string[] = [];
|
|
5277
5332
|
const filePaths = isDs
|
|
5278
|
-
? await findFiles(
|
|
5279
|
-
|
|
5333
|
+
? await findFiles(
|
|
5334
|
+
groupAbs,
|
|
5335
|
+
groupRel,
|
|
5336
|
+
['.tsx', '.html', '.md', '.css', '.json', ...PREVIEW_ASSET_EXTS],
|
|
5337
|
+
dirs
|
|
5338
|
+
)
|
|
5339
|
+
: await findFiles(
|
|
5340
|
+
groupAbs,
|
|
5341
|
+
groupRel,
|
|
5342
|
+
['.tsx', '.html', '.css', '.json', ...PREVIEW_ASSET_EXTS],
|
|
5343
|
+
dirs
|
|
5344
|
+
);
|
|
5280
5345
|
// DDR-093 — record each `.tsx` canvas's design system. canvasUrl() only
|
|
5281
5346
|
// injects tokens for `.tsx`, so skip everything else. Path-owned DS wins
|
|
5282
5347
|
// (system/<ds>/…); otherwise the sidecar's `meta.designSystem`, defaulting
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file artboard-hug-commit.ts
|
|
3
|
+
* @purpose Pure decision for what the Artboard Inspector's Height field
|
|
4
|
+
* should do when a value is committed (issue #79 — typing a height
|
|
5
|
+
* while the artboard hugs its content silently did nothing; Width
|
|
6
|
+
* worked because it has no such mode). Kept framework-free + in a
|
|
7
|
+
* .ts file so it's unit-testable (app.jsx is bundled JSX, and its
|
|
8
|
+
* module-scope `createRoot(...).render(...)` makes it unsafe to
|
|
9
|
+
* import directly in a test) — same shape as sizing-mode.ts.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type HeightCommitAction =
|
|
13
|
+
| { kind: 'resize'; height: number }
|
|
14
|
+
| { kind: 'promote-to-fixed'; height: number }
|
|
15
|
+
| { kind: 'noop' };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Fixed mode: a typed height is an ordinary resize, same lane as Width.
|
|
19
|
+
* Hug mode (DDR-027 Design Decision 1 — height is a content-driven floor,
|
|
20
|
+
* not an exact size): a typed height now PROMOTES the artboard to Fixed at
|
|
21
|
+
* that height — the same "freeze current height" write the Hug/Fixed
|
|
22
|
+
* segmented toggle already performs, just seeded from the typed value
|
|
23
|
+
* instead of the last measured one — rather than being silently dropped.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveHeightCommit(fixed: boolean, typedHeight: number): HeightCommitAction {
|
|
26
|
+
const height =
|
|
27
|
+
Number.isFinite(typedHeight) && typedHeight > 0 ? Math.round(typedHeight) : undefined;
|
|
28
|
+
if (height == null) return { kind: 'noop' };
|
|
29
|
+
return fixed ? { kind: 'resize', height } : { kind: 'promote-to-fixed', height };
|
|
30
|
+
}
|
|
@@ -20,6 +20,7 @@ import { GRID_KEYWORD_UNITS, parseTrackList, serializeTrackList } from '../grid-
|
|
|
20
20
|
// Inspector's paper-preset picker, same "pull only pure math/data" shape as
|
|
21
21
|
// canvas-cursors.ts above.
|
|
22
22
|
import { PAPER_PRESETS, resolvePrintArtboard } from '../print/units.ts';
|
|
23
|
+
import { resolveHeightCommit } from '../artboard-hug-commit.ts';
|
|
23
24
|
import { sizingModeOf, sizingModePatch } from '../sizing-mode.ts';
|
|
24
25
|
// The single "what is the hub link doing" rule, shared with the cloud rail's
|
|
25
26
|
// connect note so the two surfaces can never disagree. Pure data + strings —
|
|
@@ -38,9 +39,11 @@ import OnboardingWizard from './panels/OnboardingWizard.jsx';
|
|
|
38
39
|
import { ReadinessDialog } from './panels/ReadinessList.jsx';
|
|
39
40
|
import IntroVideoDialog from './panels/IntroVideoDialog.jsx';
|
|
40
41
|
import BrandUploadPanel from './panels/BrandUploadPanel.jsx';
|
|
42
|
+
import { FilePreview, sanitizeDisplayText } from './panels/file-preview.jsx';
|
|
41
43
|
import SetupChecklistDialog, { useSetupReadiness } from './panels/SetupChecklist.jsx';
|
|
42
44
|
import TimelinePanel from './panels/TimelinePanel.jsx';
|
|
43
45
|
import { parseCompTimeline } from './panels/timeline-parse.js';
|
|
46
|
+
import { durationFramesForDrop, probeMediaDuration } from './panels/timeline-media-cache.js';
|
|
44
47
|
import GenerateDialog from './generate-dialog.jsx';
|
|
45
48
|
import RepoBranchSwitcher from './panels/RepoBranchSwitcher.jsx';
|
|
46
49
|
import SettingsPanel from './panels/SettingsPanel.jsx';
|
|
@@ -189,6 +192,23 @@ function DockSlot({ side, width, open, ids, activeId, onPick, children, labels =
|
|
|
189
192
|
);
|
|
190
193
|
}
|
|
191
194
|
const CANVAS_EXT_RE = /\.(tsx|html?)$/i;
|
|
195
|
+
// feature-studio-file-preview — classifies a non-canvas tree row so FileRow
|
|
196
|
+
// can open an inline preview instead of the old inert no-op. Kept in sync
|
|
197
|
+
// with apps/studio/api.ts's PREVIEW_ASSET_EXTS (server won't list anything
|
|
198
|
+
// outside this set anyway, but the client stays explicit rather than
|
|
199
|
+
// assuming server-side filtering).
|
|
200
|
+
const PREVIEW_KIND_RULES = [
|
|
201
|
+
[/\.md$/i, 'markdown'],
|
|
202
|
+
[/\.(css|json|txt|ya?ml)$/i, 'text'],
|
|
203
|
+
[/\.(svg|png|jpe?g|gif|webp|avif)$/i, 'image'],
|
|
204
|
+
[/\.(mp4|webm|mov)$/i, 'video'],
|
|
205
|
+
[/\.(mp3|wav|ogg|m4a)$/i, 'audio'],
|
|
206
|
+
[/\.(woff2?|ttf|otf)$/i, 'font'],
|
|
207
|
+
];
|
|
208
|
+
function previewKind(name) {
|
|
209
|
+
for (const [re, kind] of PREVIEW_KIND_RULES) if (re.test(name)) return kind;
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
192
212
|
// Shared testid-slug derivation (desktop-e2e skill convention: kebab-case,
|
|
193
213
|
// designRoot-stripped, extension-stripped) — mirrors FileRow's inline
|
|
194
214
|
// `canvas-row-<slug>` computation so DirRow / the row-menu trigger use the
|
|
@@ -2024,7 +2044,9 @@ function DsFolderRow({ name, dsName, depth, defaultOpen, active, onOpenSystem, c
|
|
|
2024
2044
|
function FileRow({
|
|
2025
2045
|
file,
|
|
2026
2046
|
activePath,
|
|
2047
|
+
previewPath,
|
|
2027
2048
|
onOpen,
|
|
2049
|
+
onPreview,
|
|
2028
2050
|
onDelete,
|
|
2029
2051
|
openCount: oc,
|
|
2030
2052
|
depth,
|
|
@@ -2035,12 +2057,20 @@ function FileRow({
|
|
|
2035
2057
|
drag,
|
|
2036
2058
|
menu,
|
|
2037
2059
|
}) {
|
|
2038
|
-
|
|
2060
|
+
// feature-studio-file-preview (a11y fix) — a previewed row must expose
|
|
2061
|
+
// aria-selected too, or a screen-reader user gets no confirmation their
|
|
2062
|
+
// click did anything (a11y-auditor finding: state never changed).
|
|
2063
|
+
const isSel = file.path === activePath || file.path === previewPath;
|
|
2039
2064
|
const isCanvas = CANVAS_EXT_RE.test(file.name);
|
|
2040
|
-
//
|
|
2041
|
-
//
|
|
2042
|
-
//
|
|
2043
|
-
|
|
2065
|
+
// feature-studio-file-preview — RUNTIME rows (_active.json, _server.json, …)
|
|
2066
|
+
// stay inert no matter their extension: they're gitignored process state,
|
|
2067
|
+
// never user-facing content, and must not become previewable just because
|
|
2068
|
+
// `.json` also matches the `text` preview kind (DDR-115 exclusion).
|
|
2069
|
+
const pKind = !isCanvas && kind !== 'runtime' ? previewKind(file.name) : null;
|
|
2070
|
+
// Non-canvas, non-previewable rows (RUNTIME files, unrecognized extensions)
|
|
2071
|
+
// are display-only — clicking them doesn't open anything; we leave the
|
|
2072
|
+
// click as no-op + cursor hint via `aria-disabled`.
|
|
2073
|
+
const inert = !isCanvas && !pKind;
|
|
2044
2074
|
const label = isCanvas ? displayName(file.name) : file.name;
|
|
2045
2075
|
// Delete only real canvases in a deletable group (onDelete is undefined for the
|
|
2046
2076
|
// DS group + runtime files); the server enforces the rest.
|
|
@@ -2087,7 +2117,8 @@ function FileRow({
|
|
|
2087
2117
|
style={{ paddingLeft: TREE_INDENT_BASE + depth * TREE_INDENT_STEP + 'px' }}
|
|
2088
2118
|
title={file.path + (oc ? ` — ${oc} open` : inert ? ' (file index only)' : '')}
|
|
2089
2119
|
onClick={() => {
|
|
2090
|
-
if (
|
|
2120
|
+
if (isCanvas) onOpen(file.path);
|
|
2121
|
+
else if (pKind) onPreview?.(file.path);
|
|
2091
2122
|
}}
|
|
2092
2123
|
onContextMenu={
|
|
2093
2124
|
canMove ? (e) => menu.openAt(e, { kind: 'file', path: file.path, dir: fileDir }) : undefined
|
|
@@ -2158,7 +2189,9 @@ function CanvasRow({
|
|
|
2158
2189
|
depth,
|
|
2159
2190
|
kind,
|
|
2160
2191
|
activePath,
|
|
2192
|
+
previewPath,
|
|
2161
2193
|
onOpen,
|
|
2194
|
+
onPreview,
|
|
2162
2195
|
onDelete,
|
|
2163
2196
|
openCount: oc,
|
|
2164
2197
|
showHidden,
|
|
@@ -2182,7 +2215,9 @@ function CanvasRow({
|
|
|
2182
2215
|
<FileRow
|
|
2183
2216
|
file={primary}
|
|
2184
2217
|
activePath={activePath}
|
|
2218
|
+
previewPath={previewPath}
|
|
2185
2219
|
onOpen={onOpen}
|
|
2220
|
+
onPreview={onPreview}
|
|
2186
2221
|
onDelete={onDelete}
|
|
2187
2222
|
openCount={oc}
|
|
2188
2223
|
depth={depth}
|
|
@@ -2282,7 +2317,9 @@ function CanvasRow({
|
|
|
2282
2317
|
key={sc.path}
|
|
2283
2318
|
file={sc}
|
|
2284
2319
|
activePath={activePath}
|
|
2320
|
+
previewPath={previewPath}
|
|
2285
2321
|
onOpen={onOpen}
|
|
2322
|
+
onPreview={onPreview}
|
|
2286
2323
|
openCount={0}
|
|
2287
2324
|
depth={depth + 1}
|
|
2288
2325
|
kind={kind}
|
|
@@ -2296,7 +2333,9 @@ function CanvasRow({
|
|
|
2296
2333
|
function Tree({
|
|
2297
2334
|
node,
|
|
2298
2335
|
activePath,
|
|
2336
|
+
previewPath,
|
|
2299
2337
|
onOpen,
|
|
2338
|
+
onPreview,
|
|
2300
2339
|
commentsByFile,
|
|
2301
2340
|
depth = 1,
|
|
2302
2341
|
kind,
|
|
@@ -2324,8 +2363,20 @@ function Tree({
|
|
|
2324
2363
|
const files = node._files || [];
|
|
2325
2364
|
// VS Code-style sidecar grouping. Canvas (`.tsx`/`.html`) becomes the primary
|
|
2326
2365
|
// row; same-basename non-canvas files (`.meta.json`, `.css`, …) collapse
|
|
2327
|
-
// under it.
|
|
2366
|
+
// under it. Meta/doc orphans (README.md, tokens.css, …) surface only when
|
|
2367
|
+
// `showHidden` is on; media/asset orphans (images, fonts, video, audio —
|
|
2368
|
+
// feature-studio-file-preview) are content the user asked to see by
|
|
2369
|
+
// default, not sidecar noise, so they always render.
|
|
2328
2370
|
const { canvases, orphans } = useMemo(() => groupBySidecar(files), [files]);
|
|
2371
|
+
const { mediaOrphans, metaOrphans } = useMemo(() => {
|
|
2372
|
+
const media = [];
|
|
2373
|
+
const meta = [];
|
|
2374
|
+
for (const entry of orphans) {
|
|
2375
|
+
const k = previewKind(entry.primary.name);
|
|
2376
|
+
(k === 'image' || k === 'video' || k === 'audio' || k === 'font' ? media : meta).push(entry);
|
|
2377
|
+
}
|
|
2378
|
+
return { mediaOrphans: media, metaOrphans: meta };
|
|
2379
|
+
}, [orphans]);
|
|
2329
2380
|
const hasSearch = !!(search && search.trim());
|
|
2330
2381
|
// DS-folder lookup: only meaningful at the top level of a DS group. The
|
|
2331
2382
|
// server emits `dsFolders: [{name, folder}, ...]` so the client knows which
|
|
@@ -2351,7 +2402,9 @@ function Tree({
|
|
|
2351
2402
|
primary={entry.primary}
|
|
2352
2403
|
sidecars={entry.sidecars}
|
|
2353
2404
|
activePath={activePath}
|
|
2405
|
+
previewPath={previewPath}
|
|
2354
2406
|
onOpen={onOpen}
|
|
2407
|
+
onPreview={onPreview}
|
|
2355
2408
|
onDelete={onDelete}
|
|
2356
2409
|
openCount={openCount(commentsByFile[entry.primary.path])}
|
|
2357
2410
|
depth={depth}
|
|
@@ -2365,13 +2418,28 @@ function Tree({
|
|
|
2365
2418
|
/>
|
|
2366
2419
|
);
|
|
2367
2420
|
})}
|
|
2421
|
+
{mediaOrphans.map((entry) => (
|
|
2422
|
+
<FileRow
|
|
2423
|
+
key={entry.primary.path}
|
|
2424
|
+
file={entry.primary}
|
|
2425
|
+
activePath={activePath}
|
|
2426
|
+
previewPath={previewPath}
|
|
2427
|
+
onOpen={onOpen}
|
|
2428
|
+
onPreview={onPreview}
|
|
2429
|
+
openCount={openCount(commentsByFile[entry.primary.path])}
|
|
2430
|
+
depth={depth}
|
|
2431
|
+
kind={kind}
|
|
2432
|
+
/>
|
|
2433
|
+
))}
|
|
2368
2434
|
{showHidden &&
|
|
2369
|
-
|
|
2435
|
+
metaOrphans.map((entry) => (
|
|
2370
2436
|
<FileRow
|
|
2371
2437
|
key={entry.primary.path}
|
|
2372
2438
|
file={entry.primary}
|
|
2373
2439
|
activePath={activePath}
|
|
2440
|
+
previewPath={previewPath}
|
|
2374
2441
|
onOpen={onOpen}
|
|
2442
|
+
onPreview={onPreview}
|
|
2375
2443
|
openCount={openCount(commentsByFile[entry.primary.path])}
|
|
2376
2444
|
depth={depth}
|
|
2377
2445
|
kind={kind}
|
|
@@ -2385,7 +2453,9 @@ function Tree({
|
|
|
2385
2453
|
<Tree
|
|
2386
2454
|
node={node[d]}
|
|
2387
2455
|
activePath={activePath}
|
|
2456
|
+
previewPath={previewPath}
|
|
2388
2457
|
onOpen={onOpen}
|
|
2458
|
+
onPreview={onPreview}
|
|
2389
2459
|
commentsByFile={commentsByFile}
|
|
2390
2460
|
depth={depth + 1}
|
|
2391
2461
|
kind={kind}
|
|
@@ -2468,8 +2538,10 @@ function Sidebar({
|
|
|
2468
2538
|
cloud,
|
|
2469
2539
|
groups,
|
|
2470
2540
|
activePath,
|
|
2541
|
+
previewPath,
|
|
2471
2542
|
activeDsName,
|
|
2472
2543
|
onOpen,
|
|
2544
|
+
onPreview,
|
|
2473
2545
|
onOpenSystem,
|
|
2474
2546
|
wsConnected,
|
|
2475
2547
|
search,
|
|
@@ -2829,7 +2901,9 @@ function Sidebar({
|
|
|
2829
2901
|
<Tree
|
|
2830
2902
|
node={g.tree}
|
|
2831
2903
|
activePath={activePath}
|
|
2904
|
+
previewPath={previewPath}
|
|
2832
2905
|
onOpen={onOpen}
|
|
2906
|
+
onPreview={onPreview}
|
|
2833
2907
|
commentsByFile={commentsByFile}
|
|
2834
2908
|
depth={1}
|
|
2835
2909
|
kind={g.kind}
|
|
@@ -4205,10 +4279,38 @@ function Viewport({
|
|
|
4205
4279
|
onIframeLoad,
|
|
4206
4280
|
showQuickSetup,
|
|
4207
4281
|
onStartQuickSetup,
|
|
4282
|
+
previewPath,
|
|
4208
4283
|
}) {
|
|
4284
|
+
// feature-studio-file-preview (a11y fix) — a11y-auditor found the preview
|
|
4285
|
+
// overlay gave keyboard/AT users no signal anything happened: the click
|
|
4286
|
+
// stayed on the (unrelated) tree row, nothing was announced, and the
|
|
4287
|
+
// scrollable region itself wasn't in the tab order. Moving focus onto the
|
|
4288
|
+
// region on open covers all three: it's a real focus change (AT announces
|
|
4289
|
+
// the new `role="region"` + its label), and a focused element with
|
|
4290
|
+
// `overflow: auto` is keyboard-scrollable (arrow/Page keys) without a
|
|
4291
|
+
// separate tabIndex fix.
|
|
4292
|
+
const previewRef = useRef(null);
|
|
4293
|
+
useEffect(() => {
|
|
4294
|
+
if (previewPath) previewRef.current?.focus();
|
|
4295
|
+
}, [previewPath]);
|
|
4209
4296
|
return (
|
|
4210
4297
|
<div className="viewport st-stage" data-tour="viewport">
|
|
4211
|
-
{
|
|
4298
|
+
{previewPath && (
|
|
4299
|
+
// feature-studio-file-preview — an overlay, not a tab: the canvas
|
|
4300
|
+
// iframe (if any) stays mounted underneath so switching back to it
|
|
4301
|
+
// is instant and never remounts. previewPath is intentionally never
|
|
4302
|
+
// written into `tabs`/`activePath` (see App()'s onPreview).
|
|
4303
|
+
<div
|
|
4304
|
+
ref={previewRef}
|
|
4305
|
+
className="st-file-preview-overlay"
|
|
4306
|
+
role="region"
|
|
4307
|
+
aria-label={`Preview: ${sanitizeDisplayText(basename(previewPath))}`}
|
|
4308
|
+
tabIndex={0}
|
|
4309
|
+
>
|
|
4310
|
+
<FilePreview path={previewPath} kind={previewKind(basename(previewPath))} />
|
|
4311
|
+
</div>
|
|
4312
|
+
)}
|
|
4313
|
+
{tabs.length === 0 && !previewPath && (
|
|
4212
4314
|
<div className="st-empty">
|
|
4213
4315
|
<div className="st-empty-brand">
|
|
4214
4316
|
<span className="st-brand-mark">
|
|
@@ -4567,6 +4669,7 @@ function StatusBar({
|
|
|
4567
4669
|
unpushed = 0,
|
|
4568
4670
|
changesOpen = false,
|
|
4569
4671
|
onOpenChanges,
|
|
4672
|
+
version,
|
|
4570
4673
|
}) {
|
|
4571
4674
|
const isSystem = activePath === SYSTEM_TAB;
|
|
4572
4675
|
const text =
|
|
@@ -4690,6 +4793,19 @@ function StatusBar({
|
|
|
4690
4793
|
</span>
|
|
4691
4794
|
)}
|
|
4692
4795
|
|
|
4796
|
+
{/* Which release this is. Reading it used to mean probing production by
|
|
4797
|
+
hand — and the desktop, the browser and a cloud tab all serve the same
|
|
4798
|
+
client, so one slot covers all three. Absent on an older server that
|
|
4799
|
+
omits `version` from /_config, exactly as `cloud` and `canvasToken`
|
|
4800
|
+
already are. */}
|
|
4801
|
+
{version && (
|
|
4802
|
+
<span className="st-sb-slot st-sb-version" role="group" aria-label="Maude version">
|
|
4803
|
+
<span className="val" data-testid="statusbar-version">
|
|
4804
|
+
v{version}
|
|
4805
|
+
</span>
|
|
4806
|
+
</span>
|
|
4807
|
+
)}
|
|
4808
|
+
|
|
4693
4809
|
<button
|
|
4694
4810
|
type="button"
|
|
4695
4811
|
className="st-sb-theme"
|
|
@@ -8148,11 +8264,17 @@ function ArtboardKnobs({
|
|
|
8148
8264
|
onResizeArtboard?.(artboardId, resolved.widthPx, resolved.heightPx);
|
|
8149
8265
|
onSetArtboardPrint?.(artboardId, next);
|
|
8150
8266
|
};
|
|
8151
|
-
const setHug = (nextFixed) => {
|
|
8267
|
+
const setHug = (nextFixed, explicitHeight) => {
|
|
8152
8268
|
if (!artboardId) return;
|
|
8153
|
-
const
|
|
8269
|
+
const src = Number.isFinite(explicitHeight) ? explicitHeight : h;
|
|
8270
|
+
const freezeHeight = nextFixed && Number.isFinite(src) ? Math.round(src) : undefined;
|
|
8154
8271
|
onSetArtboardHug?.(artboardId, nextFixed, freezeHeight);
|
|
8155
8272
|
};
|
|
8273
|
+
const commitHeight = (n) => {
|
|
8274
|
+
const action = resolveHeightCommit(fixed, n);
|
|
8275
|
+
if (action.kind === 'resize') commitSize(null, action.height);
|
|
8276
|
+
else if (action.kind === 'promote-to-fixed') setHug(true, action.height);
|
|
8277
|
+
};
|
|
8156
8278
|
const commitStyle = (patch) => {
|
|
8157
8279
|
if (!artboardId) return;
|
|
8158
8280
|
onSetArtboardStyle?.(artboardId, patch);
|
|
@@ -8178,11 +8300,7 @@ function ArtboardKnobs({
|
|
|
8178
8300
|
lead="H"
|
|
8179
8301
|
steppers={false}
|
|
8180
8302
|
scrub={fixed}
|
|
8181
|
-
onCommit={
|
|
8182
|
-
// Hug — height is content-driven; ignore a typed value rather
|
|
8183
|
-
// than silently switching modes.
|
|
8184
|
-
if (fixed) commitSize(null, n);
|
|
8185
|
-
}}
|
|
8303
|
+
onCommit={commitHeight}
|
|
8186
8304
|
/>
|
|
8187
8305
|
</div>
|
|
8188
8306
|
<div style={{ padding: '0 12px 8px' }}>
|
|
@@ -9176,6 +9294,14 @@ function App() {
|
|
|
9176
9294
|
const [project, setProject] = useState('Design');
|
|
9177
9295
|
const [tabs, setTabs] = useState([]);
|
|
9178
9296
|
const [activePath, setActivePath] = useState(null);
|
|
9297
|
+
// feature-studio-file-preview — deliberately separate from `tabs`/
|
|
9298
|
+
// `activePath`: a previewed file was never a canvas, so it must never
|
|
9299
|
+
// trigger the WS `active`/`tabs` broadcasts, the compile-skeleton loading
|
|
9300
|
+
// state, or iframe registration that openTab()/openSystem() drive.
|
|
9301
|
+
const [previewPath, setPreviewPath] = useState(null);
|
|
9302
|
+
const onPreview = useCallback((path) => {
|
|
9303
|
+
setPreviewPath(path);
|
|
9304
|
+
}, []);
|
|
9179
9305
|
const [selected, setSelected] = useState(null);
|
|
9180
9306
|
// Phase 12.3 — latest selection, readable from the (stale-closure) onMessage
|
|
9181
9307
|
// handler so an HMR reload (triggered by a CSS/attr edit) can re-select the
|
|
@@ -9446,6 +9572,10 @@ function App() {
|
|
|
9446
9572
|
// both of these went missing and the cloud studio rendered without
|
|
9447
9573
|
// its chrome and with every canvas iframe unauthenticated.
|
|
9448
9574
|
canvasToken: data.canvasToken,
|
|
9575
|
+
// Which release this server is — rendered as the status-bar chip.
|
|
9576
|
+
// Undefined on a server too old to report it, which is why the chip
|
|
9577
|
+
// renders conditionally rather than printing `vundefined`.
|
|
9578
|
+
version: data.version,
|
|
9449
9579
|
// C2/C4 — `{ dashboardUrl, projectName, user, role }` when this is a
|
|
9450
9580
|
// cloud tab. Its presence is what tells the shared client it is in a
|
|
9451
9581
|
// browser tab on somebody else's machine; `user`/`role` are what let
|
|
@@ -11170,6 +11300,7 @@ function App() {
|
|
|
11170
11300
|
});
|
|
11171
11301
|
setActivePath(path);
|
|
11172
11302
|
setFocusedCommentId(null);
|
|
11303
|
+
setPreviewPath(null);
|
|
11173
11304
|
// Canvas-compile skeleton — cleared by the iframe's dgn:'loaded' message,
|
|
11174
11305
|
// the onLoad fallback timer (legacy .html), or a hard 15s cap.
|
|
11175
11306
|
if (path !== SYSTEM_TAB) setLoadingPath(path);
|
|
@@ -14076,8 +14207,10 @@ function App() {
|
|
|
14076
14207
|
readOnly={viewerMode}
|
|
14077
14208
|
groups={groups}
|
|
14078
14209
|
activePath={activePath}
|
|
14210
|
+
previewPath={previewPath}
|
|
14079
14211
|
activeDsName={activePath === SYSTEM_TAB ? (systemData?.ds?.name ?? null) : null}
|
|
14080
14212
|
onOpen={openTab}
|
|
14213
|
+
onPreview={onPreview}
|
|
14081
14214
|
onOpenSystem={openSystem}
|
|
14082
14215
|
wsConnected={wsConnected}
|
|
14083
14216
|
search={search}
|
|
@@ -14419,6 +14552,7 @@ function App() {
|
|
|
14419
14552
|
isNativeApp() && !viewerMode && !!setupReadiness && !setupReadiness.ready
|
|
14420
14553
|
}
|
|
14421
14554
|
onStartQuickSetup={() => setQuickSetupOpen(true)}
|
|
14555
|
+
previewPath={previewPath}
|
|
14422
14556
|
/>
|
|
14423
14557
|
</div>
|
|
14424
14558
|
{rightActive && (
|
|
@@ -14789,10 +14923,11 @@ function App() {
|
|
|
14789
14923
|
// fresh "New Cut" canvas only when no artboard opted in.
|
|
14790
14924
|
let upgraded = false;
|
|
14791
14925
|
for (const c of clips) {
|
|
14926
|
+
const probedSec = await probeMediaDuration(c.src).catch(() => null);
|
|
14792
14927
|
const body = {
|
|
14793
14928
|
canvas,
|
|
14794
14929
|
lane: c.mediaKind === 'audio' && upgraded ? 'audio' : 'storyline',
|
|
14795
|
-
durationInFrames:
|
|
14930
|
+
durationInFrames: durationFramesForDrop(fps, probedSec),
|
|
14796
14931
|
mediaTag: c.mediaKind === 'audio' ? 'Audio' : 'Video',
|
|
14797
14932
|
src: c.src,
|
|
14798
14933
|
};
|
|
@@ -14863,6 +14998,12 @@ function App() {
|
|
|
14863
14998
|
continue;
|
|
14864
14999
|
}
|
|
14865
15000
|
const isAudio = mediaTag === 'Audio';
|
|
15001
|
+
// Video clips carry a real duration — probe it so the drop
|
|
15002
|
+
// lands at the clip's own length instead of a fixed 3s that
|
|
15003
|
+
// the user then has to drag out by hand. Images have no
|
|
15004
|
+
// inherent duration, so they keep the fallback.
|
|
15005
|
+
const probedSec =
|
|
15006
|
+
mediaTag === 'Video' ? await probeMediaDuration(up.path).catch(() => null) : null;
|
|
14866
15007
|
const body = { canvas, artboardId, mediaTag, src: up.path };
|
|
14867
15008
|
if (pos && (isAudio || pos.lane === 'audio')) {
|
|
14868
15009
|
body.lane = 'audio';
|
|
@@ -14875,7 +15016,7 @@ function App() {
|
|
|
14875
15016
|
body.index = slot;
|
|
14876
15017
|
slot += 1; // multiple files insert in order
|
|
14877
15018
|
}
|
|
14878
|
-
body.durationInFrames =
|
|
15019
|
+
body.durationInFrames = durationFramesForDrop(fps, probedSec);
|
|
14879
15020
|
} else if (pos?.lane === 'overlay' && !isAudio) {
|
|
14880
15021
|
if ((timelineSequences || []).length === 0) {
|
|
14881
15022
|
// First clip of a greenfield comp = the BASE layer →
|
|
@@ -14885,14 +15026,14 @@ function App() {
|
|
|
14885
15026
|
body.lane = 'overlay';
|
|
14886
15027
|
body.from = Math.max(0, pos.frame ?? 0);
|
|
14887
15028
|
}
|
|
14888
|
-
body.durationInFrames =
|
|
15029
|
+
body.durationInFrames = durationFramesForDrop(fps, probedSec);
|
|
14889
15030
|
} else if (!isAudio) {
|
|
14890
15031
|
// Default container rule: a video/image drop ANYWHERE on the
|
|
14891
15032
|
// timeline lands in the storyline (append = hard cut at the
|
|
14892
15033
|
// end; the caret gives it an index). The old lane-less
|
|
14893
15034
|
// append refused hard-cut series ("no transition to clone").
|
|
14894
15035
|
body.lane = 'storyline';
|
|
14895
|
-
body.durationInFrames =
|
|
15036
|
+
body.durationInFrames = durationFramesForDrop(fps, probedSec);
|
|
14896
15037
|
} else {
|
|
14897
15038
|
// Audio without a caret → audio band, from the start.
|
|
14898
15039
|
body.lane = 'audio';
|
|
@@ -15168,6 +15309,7 @@ function App() {
|
|
|
15168
15309
|
unpushed={gitStatus?.unpushed || 0}
|
|
15169
15310
|
changesOpen={changesOpen}
|
|
15170
15311
|
onOpenChanges={gitStatus?.repo ? () => setChangesOpen(true) : undefined}
|
|
15312
|
+
version={cfg?.version}
|
|
15171
15313
|
/>
|
|
15172
15314
|
</div>
|
|
15173
15315
|
{presentMode && (
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
// Compact, XSS-safe markdown renderer for the ACP chat feed
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// Compact, XSS-safe markdown renderer for the ACP chat feed (and, since
|
|
2
|
+
// feature-studio-file-preview, arbitrary repo .md files previewed in the
|
|
3
|
+
// Files tree). Builds React nodes directly (never innerHTML) so neither
|
|
4
|
+
// agent output nor untrusted file content can inject markup. Covers the
|
|
5
|
+
// subset a coding agent actually emits plus GFM pipe tables (common in repo
|
|
6
|
+
// docs): fenced code blocks, headings, bullet + numbered lists, pipe tables,
|
|
7
|
+
// bold / italic / inline code, and http(s) links. Anything else falls
|
|
8
|
+
// through as plain (pre-wrapped) text. No new dependency.
|
|
6
9
|
|
|
7
10
|
function safeHref(url) {
|
|
8
11
|
// Only allow http(s) — never javascript:/data:/etc.
|
|
@@ -47,6 +50,33 @@ function inline(text) {
|
|
|
47
50
|
return out;
|
|
48
51
|
}
|
|
49
52
|
|
|
53
|
+
// GFM pipe tables: a header row, a separator row (---/:---/---:/:---: per
|
|
54
|
+
// column), then zero or more data rows — all lines containing at least one
|
|
55
|
+
// unescaped `|`.
|
|
56
|
+
const TABLE_ROW_RE = /\|/;
|
|
57
|
+
const TABLE_SEP_CELL_RE = /^:?-+:?$/;
|
|
58
|
+
|
|
59
|
+
function splitTableRow(line) {
|
|
60
|
+
let l = line.trim();
|
|
61
|
+
if (l.startsWith('|')) l = l.slice(1);
|
|
62
|
+
if (l.endsWith('|')) l = l.slice(0, -1);
|
|
63
|
+
// Split on `|` not preceded by `\` (an escaped pipe stays literal in a cell).
|
|
64
|
+
return l.split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, '|'));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function tableSeparatorAligns(line) {
|
|
68
|
+
const cells = splitTableRow(line);
|
|
69
|
+
if (!cells.length || !cells.every((c) => TABLE_SEP_CELL_RE.test(c))) return null;
|
|
70
|
+
return cells.map((c) => {
|
|
71
|
+
const left = c.startsWith(':');
|
|
72
|
+
const right = c.endsWith(':');
|
|
73
|
+
if (left && right) return 'center';
|
|
74
|
+
if (right) return 'right';
|
|
75
|
+
if (left) return 'left';
|
|
76
|
+
return null;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
50
80
|
export function Markdown({ text }) {
|
|
51
81
|
const lines = String(text).split('\n');
|
|
52
82
|
const blocks = [];
|
|
@@ -108,6 +138,47 @@ export function Markdown({ text }) {
|
|
|
108
138
|
continue;
|
|
109
139
|
}
|
|
110
140
|
|
|
141
|
+
// GFM pipe table: header row + a valid separator row immediately after.
|
|
142
|
+
if (TABLE_ROW_RE.test(line) && i + 1 < lines.length) {
|
|
143
|
+
const aligns = tableSeparatorAligns(lines[i + 1]);
|
|
144
|
+
if (aligns) {
|
|
145
|
+
const header = splitTableRow(line);
|
|
146
|
+
i += 2;
|
|
147
|
+
const rows = [];
|
|
148
|
+
while (i < lines.length && lines[i].trim() !== '' && TABLE_ROW_RE.test(lines[i])) {
|
|
149
|
+
rows.push(splitTableRow(lines[i]));
|
|
150
|
+
i++;
|
|
151
|
+
}
|
|
152
|
+
blocks.push(
|
|
153
|
+
<div key={key++} className="chat-md-table-wrap">
|
|
154
|
+
<table className="chat-md-table">
|
|
155
|
+
<thead>
|
|
156
|
+
<tr>
|
|
157
|
+
{header.map((c, ci) => (
|
|
158
|
+
<th key={ci} style={aligns[ci] ? { textAlign: aligns[ci] } : undefined}>
|
|
159
|
+
{inline(c)}
|
|
160
|
+
</th>
|
|
161
|
+
))}
|
|
162
|
+
</tr>
|
|
163
|
+
</thead>
|
|
164
|
+
<tbody>
|
|
165
|
+
{rows.map((r, ri) => (
|
|
166
|
+
<tr key={ri}>
|
|
167
|
+
{header.map((_, ci) => (
|
|
168
|
+
<td key={ci} style={aligns[ci] ? { textAlign: aligns[ci] } : undefined}>
|
|
169
|
+
{r[ci] !== undefined ? inline(r[ci]) : ''}
|
|
170
|
+
</td>
|
|
171
|
+
))}
|
|
172
|
+
</tr>
|
|
173
|
+
))}
|
|
174
|
+
</tbody>
|
|
175
|
+
</table>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
111
182
|
// Blank line → paragraph separator.
|
|
112
183
|
if (line.trim() === '') {
|
|
113
184
|
i++;
|