@nowline/mcp 0.8.5 → 0.9.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.
Files changed (67) hide show
  1. package/dist/branding.d.ts +3 -3
  2. package/dist/branding.js +3 -3
  3. package/dist/capabilities.d.ts.map +1 -1
  4. package/dist/capabilities.js +2 -9
  5. package/dist/capabilities.js.map +1 -1
  6. package/dist/diagnostics.d.ts +46 -18
  7. package/dist/diagnostics.d.ts.map +1 -1
  8. package/dist/diagnostics.js +77 -25
  9. package/dist/diagnostics.js.map +1 -1
  10. package/dist/generated/resources.d.ts.map +1 -1
  11. package/dist/generated/resources.js +15 -3
  12. package/dist/generated/resources.js.map +1 -1
  13. package/dist/generated/ui-bundle.d.ts.map +1 -1
  14. package/dist/generated/ui-bundle.js +2 -2
  15. package/dist/generated/ui-bundle.js.map +1 -1
  16. package/dist/http.d.ts +4 -0
  17. package/dist/http.d.ts.map +1 -0
  18. package/dist/http.js +23 -0
  19. package/dist/http.js.map +1 -0
  20. package/dist/index.js +7 -13
  21. package/dist/index.js.map +1 -1
  22. package/dist/options.d.ts +26 -0
  23. package/dist/options.d.ts.map +1 -0
  24. package/dist/options.js +55 -0
  25. package/dist/options.js.map +1 -0
  26. package/dist/prompts.d.ts +1 -1
  27. package/dist/prompts.d.ts.map +1 -1
  28. package/dist/prompts.js +6 -6
  29. package/dist/prompts.js.map +1 -1
  30. package/dist/reference-cheatsheet.d.ts +1 -1
  31. package/dist/reference-cheatsheet.d.ts.map +1 -1
  32. package/dist/reference-cheatsheet.js +27 -2
  33. package/dist/reference-cheatsheet.js.map +1 -1
  34. package/dist/schema-vocab.d.ts +5 -3
  35. package/dist/schema-vocab.d.ts.map +1 -1
  36. package/dist/schema-vocab.js +9 -0
  37. package/dist/schema-vocab.js.map +1 -1
  38. package/dist/schemas.d.ts +9 -7
  39. package/dist/schemas.d.ts.map +1 -1
  40. package/dist/schemas.js +2 -0
  41. package/dist/schemas.js.map +1 -1
  42. package/dist/server.d.ts +1 -1
  43. package/dist/server.d.ts.map +1 -1
  44. package/dist/server.js +151 -59
  45. package/dist/server.js.map +1 -1
  46. package/dist/ui/entry.js +5 -12
  47. package/dist/ui/entry.js.map +1 -1
  48. package/dist/ui/payload.d.ts +14 -0
  49. package/dist/ui/payload.d.ts.map +1 -1
  50. package/dist/ui/payload.js +23 -0
  51. package/dist/ui/payload.js.map +1 -1
  52. package/package.json +24 -17
  53. package/src/branding.ts +3 -3
  54. package/src/capabilities.ts +4 -2
  55. package/src/diagnostics.ts +99 -39
  56. package/src/generated/resources.ts +15 -3
  57. package/src/generated/ui-bundle.ts +2 -2
  58. package/src/http.ts +28 -0
  59. package/src/index.ts +7 -14
  60. package/src/options.ts +79 -0
  61. package/src/prompts.ts +7 -7
  62. package/src/reference-cheatsheet.ts +27 -2
  63. package/src/schema-vocab.ts +9 -0
  64. package/src/schemas.ts +2 -0
  65. package/src/server.ts +200 -60
  66. package/src/ui/entry.ts +6 -11
  67. package/src/ui/payload.ts +26 -0
package/src/server.ts CHANGED
@@ -14,15 +14,20 @@ import {
14
14
  registerAppResource,
15
15
  registerAppTool,
16
16
  } from '@modelcontextprotocol/ext-apps/server';
17
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
17
+ import { McpServer } from '@modelcontextprotocol/server';
18
+ import type { SurfaceOptions } from '@nowline/config';
18
19
  import { type NowlineFile, parseNowlineJson, printNowlineFile } from '@nowline/core';
19
20
  import {
21
+ acceptStagedRoadmap,
20
22
  type ExportFormat,
21
23
  exportDocument,
24
+ exportStaged,
22
25
  type HostEnv,
26
+ IncludeResolveError,
23
27
  type RenderInputs,
24
28
  } from '@nowline/export';
25
29
  import { resolveFonts } from '@nowline/export-core';
30
+ import { NON_WORKING_DISPLAYS, THEME_NAMES } from '@nowline/layout';
26
31
  import { buildShareLink } from '@nowline/share-link';
27
32
  import { z } from 'zod';
28
33
  import { NOWLINE_MCP_ICONS } from './branding.js';
@@ -30,14 +35,20 @@ import { CAPABILITIES } from './capabilities.js';
30
35
  import {
31
36
  buildDocument,
32
37
  collectMcpDiagnostics,
33
- collectMcpLayoutInsights,
34
38
  DEFAULT_RENDER_WIDTH,
35
39
  diagnosticsErrorBlock,
40
+ diagnosticsErrorResponse,
36
41
  handleToolError,
37
42
  InputRequiredError,
43
+ includeDiagnosticsResponse,
38
44
  LAYOUT_INSIGHT_HINT,
45
+ layoutInsightsFromStage,
46
+ type McpInsight,
39
47
  PathOutsideRootError,
40
48
  REVIEW_MAX_WIDTH,
49
+ routedResolveDiagnosticsToMcp,
50
+ stageMcpRoadmap,
51
+ stageRoutedDiagnostics,
41
52
  toolDescriptionWithSyntax,
42
53
  } from './diagnostics.js';
43
54
  import {
@@ -47,6 +58,7 @@ import {
47
58
  REFERENCE_MAN_PAGE,
48
59
  } from './generated/resources.js';
49
60
  import { PREVIEW_HTML } from './generated/ui-bundle.js';
61
+ import { type McpResolvedOptions, resolveMcpOptions } from './options.js';
50
62
  import { registerPrompts } from './prompts.js';
51
63
  import { REFERENCE_CHEATSHEET } from './reference-cheatsheet.js';
52
64
  import { SCHEMA_VOCABULARY } from './schema-vocab.js';
@@ -107,9 +119,24 @@ interface PreviewPayload {
107
119
  theme?: string;
108
120
  now?: string;
109
121
  width?: number;
122
+ nonWorking?: 'hide' | 'show';
110
123
  locale?: string;
111
124
  }
112
125
 
126
+ /**
127
+ * The preview payload's now-line. An explicit `now` passes through. With only a
128
+ * `timezone`, the payload carries the date that zone resolved to: the preview
129
+ * draws an omitted `now` at the viewer's own date and cannot read the zone.
130
+ */
131
+ function previewNow(
132
+ args: { now?: string; timezone?: string },
133
+ today: Date | undefined,
134
+ ): string | undefined {
135
+ if (args.now !== undefined) return args.now;
136
+ if (args.timezone !== undefined && today) return today.toISOString().slice(0, 10);
137
+ return undefined;
138
+ }
139
+
113
140
  function leanPreviewBlock(payload: PreviewPayload) {
114
141
  return {
115
142
  type: 'text' as const,
@@ -119,6 +146,7 @@ function leanPreviewBlock(payload: PreviewPayload) {
119
146
  theme: payload.theme,
120
147
  now: payload.now,
121
148
  width: payload.width,
149
+ nonWorking: payload.nonWorking,
122
150
  locale: payload.locale,
123
151
  }),
124
152
  };
@@ -185,11 +213,6 @@ function createNodeHostEnv(sourcePath: string): HostEnv {
185
213
 
186
214
  // ---- Helpers ----------------------------------------------------------------
187
215
 
188
- function todayUtc(): Date {
189
- const now = new Date();
190
- return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
191
- }
192
-
193
216
  function exampleShortName(fullName: string): string {
194
217
  return fullName.endsWith('.nowline') ? fullName.slice(0, -'.nowline'.length) : fullName;
195
218
  }
@@ -335,6 +358,43 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
335
358
 
336
359
  registerPrompts(server);
337
360
 
361
+ const nonWorkingSchema = z
362
+ .enum(NON_WORKING_DISPLAYS)
363
+ .optional()
364
+ .describe(
365
+ "How a business calendar's non-working days appear: hide collapses them, " +
366
+ "show draws them as shaded bands. Overrides the file's `default roadmap non-working:` key; " +
367
+ 'omit to let the key apply (then hide). The schedule exports (mermaid, msproj, xlsx) are the same either way.',
368
+ );
369
+
370
+ // A string, not an enum, so `Greyscale` and the `greyscale` alias reach
371
+ // `resolveMcpOptions`, which rejects an unknown name with the theme list.
372
+ const themeSchema = z
373
+ .string()
374
+ .optional()
375
+ .describe(
376
+ `Color theme: ${THEME_NAMES.join(', ')} ("greyscale" is accepted). Defaults to light.`,
377
+ );
378
+
379
+ const nowSchema = z
380
+ .string()
381
+ .regex(/^(\d{4}-\d{2}-\d{2}|-)$/, 'Expected YYYY-MM-DD or "-".')
382
+ .optional()
383
+ .describe(
384
+ "Now-line date as YYYY-MM-DD. Omit to draw it at today's date in `timezone` " +
385
+ "(the server's local zone when `timezone` is omitted too); " +
386
+ 'pass "-" to suppress the now-line (same as the CLI\'s `--now -`).',
387
+ );
388
+
389
+ const timezoneSchema = z
390
+ .string()
391
+ .optional()
392
+ .describe(
393
+ "Timezone that decides today's date when `now` is omitted (ignored when `now` is a date): " +
394
+ '"local" (the default), "UTC", an ISO 8601 offset ("+05:30", "-07:00", "Z"), ' +
395
+ 'or an IANA name ("America/Los_Angeles"). The same forms as the CLI\'s `--timezone`.',
396
+ );
397
+
338
398
  // ---- validate -----------------------------------------------------------
339
399
 
340
400
  server.registerTool(
@@ -349,26 +409,39 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
349
409
  .string()
350
410
  .optional()
351
411
  .describe('Absolute or relative path to a .nowline file to validate.'),
412
+ now: nowSchema,
413
+ timezone: timezoneSchema,
352
414
  }),
353
415
  outputSchema: ValidateOutputSchema,
354
416
  annotations: readOnlyTool('Validate Roadmap'),
355
417
  },
356
418
  async (args) => {
357
419
  try {
420
+ const { options } = resolveMcpOptions(args);
358
421
  const { source, filePath } = await sourceAndPath(args, allowedRoot);
359
422
  const doc = await buildDocument(source);
360
423
  const diagnostics = collectMcpDiagnostics(doc, filePath);
424
+ // One stage: the routed (wave-rule) include diagnostics join
425
+ // the validator's, with the file and line they point into
426
+ // (specs/waves.md §6.1), and the insights come from the same
427
+ // layout, logo included.
428
+ let insights: McpInsight[] = [];
429
+ if (diagnostics.every((d) => d.severity !== 'error')) {
430
+ const stage = await stageMcpRoadmap(
431
+ doc,
432
+ source,
433
+ {
434
+ sourcePath: filePath,
435
+ today: options.today,
436
+ locale: 'en-US',
437
+ theme: 'light',
438
+ },
439
+ createNodeHostEnv(filePath),
440
+ );
441
+ diagnostics.push(...stageRoutedDiagnostics(stage));
442
+ if (stage.kind === 'staged') insights = layoutInsightsFromStage(stage);
443
+ }
361
444
  const ok = diagnostics.every((d) => d.severity !== 'error');
362
- const insights = ok
363
- ? await collectMcpLayoutInsights({
364
- source,
365
- filePath,
366
- today: todayUtc(),
367
- locale: 'en-US',
368
- readFile: createNodeHostEnv(filePath).readSource,
369
- doc,
370
- })
371
- : [];
372
445
  const structured = {
373
446
  ok,
374
447
  diagnostics,
@@ -585,15 +658,16 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
585
658
  .enum(['svg', 'png'])
586
659
  .optional()
587
660
  .describe('Output format. Defaults to svg.'),
588
- theme: z
589
- .enum(['light', 'dark', 'grayscale'])
590
- .optional()
591
- .describe('Color theme. Defaults to light.'),
592
- now: z
593
- .string()
661
+ theme: themeSchema,
662
+ now: nowSchema,
663
+ timezone: timezoneSchema,
664
+ width: z
665
+ .number()
594
666
  .optional()
595
- .describe('Now-line date as YYYY-MM-DD (UTC). Omit to suppress.'),
596
- width: z.number().optional().describe('Canvas width in px.'),
667
+ .describe(
668
+ 'Maximum canvas width in px, an integer of at least 320. Unset fits the content; a wider roadmap shrinks its timeline to fit.',
669
+ ),
670
+ nonWorking: nonWorkingSchema,
597
671
  scale: z
598
672
  .number()
599
673
  .optional()
@@ -616,7 +690,8 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
616
690
  .boolean()
617
691
  .optional()
618
692
  .describe(
619
- 'When true, force the in-chat MCP Apps preview. On MCP Apps hosts the preview auto-renders via _meta.ui without this flag.',
693
+ 'When true, force the in-chat MCP Apps preview; when false, return inline output even on an MCP Apps host. ' +
694
+ 'Omit to follow the host: on MCP Apps hosts the preview auto-renders via _meta.ui.',
620
695
  ),
621
696
  }),
622
697
  outputSchema: RenderOutputSchema,
@@ -630,6 +705,13 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
630
705
  // Only the filesystem touchpoints (reading the input, writing the
631
706
  // output) map to structured NL.MCP.* errors; a kernel render fault
632
707
  // bubbles unchanged so it is never mislabeled as a path/IO failure.
708
+ let resolved: McpResolvedOptions;
709
+ try {
710
+ resolved = resolveMcpOptions(args);
711
+ } catch (err) {
712
+ return handleToolError(err);
713
+ }
714
+ const { theme, options } = resolved;
633
715
  let source: string;
634
716
  let filePath: string;
635
717
  try {
@@ -642,17 +724,19 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
642
724
  if (!blocked.ok) return blocked.response;
643
725
 
644
726
  const format: ExportFormat = args.format ?? 'svg';
645
- const today = args.now ? new Date(`${args.now}T00:00:00Z`) : todayUtc();
727
+ const today = options.today;
646
728
  const inputs: RenderInputs = {
647
729
  sourcePath: filePath,
648
730
  today,
649
731
  locale: 'en-US',
650
- theme: args.theme ?? 'light',
651
- width: args.width,
732
+ theme: options.theme,
733
+ width: options.width,
734
+ nonWorking: options.nonWorking,
652
735
  pngScale: args.scale,
653
736
  };
654
737
  const host = createNodeHostEnv(filePath);
655
- const appActive = args.preview === true || clientSupportsAppsUi(server);
738
+ // Explicit true/false wins; undefined follows the host's capabilities.
739
+ const appActive = args.preview ?? clientSupportsAppsUi(server);
656
740
  // Skip the full render when the bytes won't be used: apps host with
657
741
  // no write-to-disk path and no review attachment requested.
658
742
  const needsRender = !appActive || !!args.output || args.review === true;
@@ -660,32 +744,49 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
660
744
  const result = await resolveFonts({ headless: true });
661
745
  inputs.fonts = { sans: result.sans, mono: result.mono };
662
746
  }
663
- const bytes = needsRender
664
- ? await exportDocument(source, format, inputs, host)
665
- : new Uint8Array(0);
666
-
667
- const insights = await collectMcpLayoutInsights({
668
- source,
669
- filePath,
670
- today,
671
- theme: args.theme ?? 'light',
672
- width: args.width,
673
- locale: 'en-US',
674
- readFile: host.readSource,
675
- doc: blocked.doc,
676
- });
747
+ // One stage serves the render, the insights and the include check.
748
+ const stage = await stageMcpRoadmap(blocked.doc, source, inputs, host);
749
+ if (stage.kind === 'invalid') {
750
+ return diagnosticsErrorResponse(
751
+ filePath,
752
+ collectMcpDiagnostics(blocked.doc, filePath),
753
+ );
754
+ }
755
+ // Wave-rule include errors are reported as diagnostics. An uncoded
756
+ // include error throws from a render, as the kernel's does; the
757
+ // preview path, which draws nothing here, keeps the preview it
758
+ // returned before waves existed (specs/waves.md §6.1).
759
+ const routed = stageRoutedDiagnostics(stage);
760
+ if (routed.some((d) => d.severity === 'error')) {
761
+ return includeDiagnosticsResponse(blocked.doc, filePath, routed);
762
+ }
763
+ if (stage.kind === 'include-error' && needsRender) acceptStagedRoadmap(stage, inputs);
764
+ const staged = stage.kind === 'staged' ? stage : undefined;
765
+ const insights = staged ? layoutInsightsFromStage(staged) : [];
766
+ const bytes =
767
+ needsRender && staged
768
+ ? await exportStaged(staged, format, inputs, host)
769
+ : new Uint8Array(0);
677
770
 
678
771
  const previewPayload: PreviewPayload = {
679
772
  source,
680
- theme: args.theme,
681
- now: args.now,
682
- width: args.width,
773
+ theme,
774
+ now: previewNow(args, options.today),
775
+ width: options.width,
776
+ nonWorking: options.nonWorking,
683
777
  locale: 'en-US',
684
778
  };
685
779
 
686
780
  const reviewBlocks =
687
781
  args.review === true
688
- ? await buildReviewContentBlocks(source, format, bytes, inputs, host)
782
+ ? await buildReviewContentBlocks(
783
+ blocked.doc,
784
+ source,
785
+ format,
786
+ bytes,
787
+ inputs,
788
+ host,
789
+ )
689
790
  : [];
690
791
 
691
792
  const insightHintBlocks =
@@ -800,10 +901,17 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
800
901
  'Never pass a virtual or sandbox path such as /home/claude/…, /mnt/user-data/…, or any path ' +
801
902
  'outside the configured output folder — those do not exist on the host filesystem.',
802
903
  ),
803
- now: z.string().optional().describe('Now-line date as YYYY-MM-DD (UTC).'),
804
- theme: z.enum(['light', 'dark', 'grayscale']).optional(),
904
+ now: nowSchema,
905
+ timezone: timezoneSchema,
906
+ theme: themeSchema,
907
+ nonWorking: nonWorkingSchema,
805
908
  scale: z.number().optional().describe('PNG scale factor.'),
806
- pageSize: z.string().optional().describe('PDF page size.'),
909
+ pageSize: z
910
+ .string()
911
+ .optional()
912
+ .describe(
913
+ 'PDF page size: a preset name, "content", or <W>x<H><unit> (e.g. 8.5x11in). Validated for every format.',
914
+ ),
807
915
  orientation: z
808
916
  .enum(['portrait', 'landscape', 'auto'])
809
917
  .optional()
@@ -832,6 +940,12 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
832
940
  // Only the filesystem touchpoints (reading the input, writing the
833
941
  // output) map to structured NL.MCP.* errors; a kernel export fault
834
942
  // bubbles unchanged so it is never mislabeled as a path/IO failure.
943
+ let options: SurfaceOptions;
944
+ try {
945
+ ({ options } = resolveMcpOptions(args));
946
+ } catch (err) {
947
+ return handleToolError(err);
948
+ }
835
949
  let source: string;
836
950
  let filePath: string;
837
951
  try {
@@ -844,14 +958,14 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
844
958
  if (!blocked.ok) return blocked.response;
845
959
 
846
960
  const format: ExportFormat = args.format as NonRenderFormat;
847
- const today = args.now ? new Date(`${args.now}T00:00:00Z`) : todayUtc();
848
961
  const inputs: RenderInputs = {
849
962
  sourcePath: filePath,
850
- today,
963
+ today: options.today,
851
964
  locale: 'en-US',
852
- theme: args.theme ?? 'light',
965
+ theme: options.theme,
966
+ nonWorking: options.nonWorking,
853
967
  pngScale: args.scale,
854
- pageSize: args.pageSize,
968
+ pageSize: options.pageSize,
855
969
  orientation: args.orientation,
856
970
  marginPt: args.marginPt,
857
971
  msprojStart: args.msprojStart,
@@ -861,7 +975,21 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
861
975
  inputs.fonts = { sans: result.sans, mono: result.mono };
862
976
  }
863
977
  const host = createNodeHostEnv(filePath);
864
- const bytes = await exportDocument(source, format, inputs, host);
978
+ let bytes: Uint8Array;
979
+ try {
980
+ const staged = acceptStagedRoadmap(
981
+ await stageMcpRoadmap(blocked.doc, source, inputs, host),
982
+ inputs,
983
+ );
984
+ bytes = await exportStaged(staged, format, inputs, host);
985
+ } catch (err) {
986
+ if (!(err instanceof IncludeResolveError)) throw err;
987
+ return includeDiagnosticsResponse(
988
+ blocked.doc,
989
+ filePath,
990
+ routedResolveDiagnosticsToMcp(err.diagnostics),
991
+ );
992
+ }
865
993
 
866
994
  const BINARY_FORMATS = new Set<ExportFormat>(['png', 'pdf', 'xlsx']);
867
995
  const isBinary = BINARY_FORMATS.has(format);
@@ -1049,12 +1177,15 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
1049
1177
  if (args.to === 'json') {
1050
1178
  const { source, filePath } = await sourceAndPath(args, allowedRoot);
1051
1179
  const host = createNodeHostEnv(filePath);
1180
+ // The JSON AST does not depend on today's date, so convert
1181
+ // takes no `now`; the kernel still wants a concrete one.
1182
+ const { options } = resolveMcpOptions({});
1052
1183
  const jsonBytes = await exportDocument(
1053
1184
  source,
1054
1185
  'json',
1055
1186
  {
1056
1187
  sourcePath: filePath,
1057
- today: todayUtc(),
1188
+ today: options.today,
1058
1189
  locale: 'en-US',
1059
1190
  theme: 'light',
1060
1191
  },
@@ -1121,7 +1252,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
1121
1252
  server.registerTool(
1122
1253
  'list-themes',
1123
1254
  {
1124
- description: 'List supported color themes: light, dark, grayscale.',
1255
+ description: `List supported color themes: ${THEME_NAMES.join(', ')}.`,
1125
1256
  inputSchema: z.object({}),
1126
1257
  outputSchema: ListItemsOutputSchema,
1127
1258
  annotations: readOnlyTool('List Themes'),
@@ -1300,7 +1431,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
1300
1431
  'schema',
1301
1432
  {
1302
1433
  description:
1303
- 'Return the structured Nowline DSL key vocabulary (directive keys, entity types, item properties).',
1434
+ 'Return the structured Nowline DSL key vocabulary (directive keys, entity types, item properties, config keywords, calendar declaration keys).',
1304
1435
  inputSchema: z.object({}),
1305
1436
  outputSchema: SchemaOutputSchema,
1306
1437
  annotations: readOnlyTool('View Roadmap Schema'),
@@ -1310,6 +1441,8 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
1310
1441
  directiveKeys: [...SCHEMA_VOCABULARY.directiveKeys],
1311
1442
  entityTypes: [...SCHEMA_VOCABULARY.entityTypes],
1312
1443
  itemPropertyKeys: [...SCHEMA_VOCABULARY.itemPropertyKeys],
1444
+ configKeywords: [...SCHEMA_VOCABULARY.configKeywords],
1445
+ calendarDeclarationKeys: [...SCHEMA_VOCABULARY.calendarDeclarationKeys],
1313
1446
  };
1314
1447
  return {
1315
1448
  content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
@@ -1383,6 +1516,7 @@ function buildExportInlineContentBlocks(
1383
1516
  }
1384
1517
 
1385
1518
  async function buildReviewContentBlocks(
1519
+ doc: Awaited<ReturnType<typeof buildDocument>>,
1386
1520
  source: string,
1387
1521
  format: ExportFormat,
1388
1522
  artifactBytes: Uint8Array,
@@ -1413,7 +1547,13 @@ async function buildReviewContentBlocks(
1413
1547
  const fonts = await resolveFonts({ headless: true });
1414
1548
  reviewInputs.fonts = { sans: fonts.sans, mono: fonts.mono };
1415
1549
  }
1416
- inspectionBytes = await exportDocument(source, 'png', reviewInputs, host);
1550
+ // A narrower review width is a different layout, so it stages again
1551
+ // (the parsed document is reused).
1552
+ const reviewStage = acceptStagedRoadmap(
1553
+ await stageMcpRoadmap(doc, source, reviewInputs, host),
1554
+ reviewInputs,
1555
+ );
1556
+ inspectionBytes = await exportStaged(reviewStage, 'png', reviewInputs, host);
1417
1557
  }
1418
1558
 
1419
1559
  if (format !== 'png' || inspectionBytes.byteLength !== artifactBytes.byteLength) {
package/src/ui/entry.ts CHANGED
@@ -25,12 +25,15 @@
25
25
  declare const __MCP_VERSION__: string;
26
26
 
27
27
  import { App } from '@modelcontextprotocol/ext-apps';
28
+ import { normalizeThemeName } from '@nowline/layout';
28
29
  import { type LivePreviewHandle, mountLivePreview } from '@nowline/preview';
29
30
  import type { NowOverride, ThemeOverride } from '@nowline/preview-shell';
30
31
  import {
31
32
  type PreviewPayload,
32
33
  parsePreviewFromArguments,
33
34
  parsePreviewFromContent,
35
+ toNonWorkingOverride,
36
+ toNowOverride,
34
37
  } from './payload.js';
35
38
 
36
39
  function readPayload(): PreviewPayload | undefined {
@@ -45,16 +48,7 @@ function readPayload(): PreviewPayload | undefined {
45
48
 
46
49
  /** Coerce a raw theme token from the payload to the shell's ThemeOverride. */
47
50
  function toThemeOverride(theme: string | undefined): ThemeOverride {
48
- switch (theme) {
49
- case 'light':
50
- case 'dark':
51
- case 'grayscale':
52
- case 'greyscale':
53
- case 'auto':
54
- return theme;
55
- default:
56
- return 'auto';
57
- }
51
+ return (theme !== undefined ? normalizeThemeName(theme) : undefined) ?? 'auto';
58
52
  }
59
53
 
60
54
  let mounted = false;
@@ -87,7 +81,8 @@ function mountFromPayload(payload: PreviewPayload, app: App): void {
87
81
  mode: hostTheme(app) ?? 'system',
88
82
  initialView: {
89
83
  theme: toThemeOverride(payload.theme),
90
- now: (payload.now ?? 'today') as NowOverride,
84
+ now: toNowOverride(payload.now) as NowOverride,
85
+ nonWorking: toNonWorkingOverride(payload.nonWorking),
91
86
  showLinks: payload.showLinks !== false,
92
87
  },
93
88
  renderOptions: {
package/src/ui/payload.ts CHANGED
@@ -13,6 +13,8 @@ export interface PreviewPayload {
13
13
  theme?: string;
14
14
  now?: string;
15
15
  width?: number;
16
+ /** The render-time non-working display; absent lets the file's own key apply. */
17
+ nonWorking?: 'hide' | 'show';
16
18
  locale?: string;
17
19
  showLinks?: boolean;
18
20
  showMinimap?: boolean;
@@ -53,11 +55,35 @@ export function parsePreviewFromArguments(
53
55
  args: Record<string, unknown> | undefined,
54
56
  ): PreviewPayload | undefined {
55
57
  if (!args || typeof args.source !== 'string') return undefined;
58
+ // A `timezone` without a `now` names a date only the server can resolve;
59
+ // wait for the result payload, which carries it.
60
+ if (typeof args.timezone === 'string' && typeof args.now !== 'string') return undefined;
56
61
  return {
57
62
  source: args.source,
58
63
  theme: typeof args.theme === 'string' ? args.theme : undefined,
59
64
  now: typeof args.now === 'string' ? args.now : undefined,
60
65
  width: typeof args.width === 'number' ? args.width : undefined,
66
+ nonWorking:
67
+ args.nonWorking === 'hide' || args.nonWorking === 'show' ? args.nonWorking : undefined,
61
68
  locale: 'en-US',
62
69
  };
63
70
  }
71
+
72
+ /**
73
+ * The shell's non-working dropdown value for a payload. A payload with no
74
+ * `nonWorking` maps to `'file'` (no override), so the file's own key applies;
75
+ * anything other than `hide` or `show` is treated as unset.
76
+ */
77
+ export function toNonWorkingOverride(nonWorking: unknown): 'file' | 'hide' | 'show' {
78
+ return nonWorking === 'hide' || nonWorking === 'show' ? nonWorking : 'file';
79
+ }
80
+
81
+ /**
82
+ * The shell's now-line value for a payload's `now`. Omitted means today; `-`
83
+ * (the CLI's `--now -` sentinel, also accepted by the `render` tool) hides the
84
+ * now-line; a date passes through.
85
+ */
86
+ export function toNowOverride(now: unknown): string {
87
+ if (now === '-') return 'hide';
88
+ return typeof now === 'string' && now.length > 0 ? now : 'today';
89
+ }