@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 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
- git tag "v$(node -p "require('./package.json').version")"
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
 
@@ -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))) out.push(rel);
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(groupAbs, groupRel, ['.tsx', '.html', '.md', '.css', '.json'], dirs)
5279
- : await findFiles(groupAbs, groupRel, ['.tsx', '.html', '.css', '.json'], dirs);
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
- const isSel = file.path === activePath;
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
- // Non-canvas rows (PROJECT *.md, RUNTIME _active.json, ...) are display-only —
2041
- // clicking them doesn't open an iframe; we leave the click as no-op + cursor
2042
- // hint via `aria-disabled`.
2043
- const inert = !isCanvas;
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 (!inert) onOpen(file.path);
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. Orphans surface only when `showHidden` is on.
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
- orphans.map((entry) => (
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
- {tabs.length === 0 && (
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 freezeHeight = nextFixed && Number.isFinite(h) ? Math.round(h) : undefined;
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={(n) => {
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: Math.round(fps * 3),
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 = Math.round(fps * 3);
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 = Math.round(fps * 3);
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 = Math.round(fps * 3);
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. Builds React nodes
2
- // directly (never innerHTML) so agent output can't inject markup. Covers the
3
- // subset a coding agent actually emits: fenced code blocks, headings, bullet +
4
- // numbered lists, bold / italic / inline code, and http(s) links. Anything else
5
- // falls through as plain (pre-wrapped) text. No new dependency.
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++;