comparison?: Comparison
comparisonId?: string
}
diff --git a/skills/writing-specs/interactive/app/src/views/scenario-example.tsx b/skills/writing-specs/interactive/app/src/views/scenario-example.tsx
new file mode 100644
index 00000000..a2918885
--- /dev/null
+++ b/skills/writing-specs/interactive/app/src/views/scenario-example.tsx
@@ -0,0 +1,273 @@
+import React, { type ReactNode } from 'react'
+import { Markdown, type MarkdownInlinePlugin } from '@astryxdesign/core/Markdown'
+import { Text } from '@astryxdesign/core/Text'
+import type { ScenarioDetails, ScenarioExample } from '../spec-types'
+import { useItemLinkPlugins } from './item-links'
+import { OFFLINE_MARKDOWN_COMPONENTS } from './shared'
+
+/** Stands in for an invented-value star while the parser runs, because Astryx pairs two trailing stars in one part as emphasis. */
+const STAR = '\uE000'
+/** Takes the place of a literal U+E000 in the text, so only a swapped star ever reads as the placeholder. */
+const LITERAL_STAR_SENTINEL = '\uE001'
+const restoreStar = (text: string) => text.replaceAll(STAR, '*').replaceAll(LITERAL_STAR_SENTINEL, STAR)
+
+/** A result or rejection phrase: its mark plus everything up to the next semicolon or line end. */
+export const EXAMPLE_PHRASE = /([✓✗])([^;\n]+)/g
+
+/** Invented-value runs start at whitespace or right after a previous value's `*,`, so each position of a long run without a star fails in constant time. */
+const INVENTED_MARK = /(?<=^|\s|\*,)([^\s*]+)\*(?=[\s,;:.)\]]|$)/g
+
+/** Colours the marks that survive Markdown parsing; the gap pattern must match the renderer's open-gap regex. */
+export const exampleMarkPlugins: MarkdownInlinePlugin[] = [
+ { pattern: /(?<=^|\s|\uE000,)[^\s\uE000]+\uE000(?=[\s,;:.)\]]|$)/g, render: (match, key) => {restoreStar(match[0])} },
+ { pattern: /\[ASK: [^\[\]\n]+\]/g, render: (match, key) => {restoreStar(match[0])} },
+]
+
+/** Last in the plugin order so it only sees placeholders the marks above left in plain text. */
+const restorePlaceholders: MarkdownInlinePlugin = {
+ pattern: /[\uE000\uE001]/g,
+ render: (match, key) => {restoreStar(match[0])},
+}
+
+/** Colours a result or rejection phrase inside one text node of the details body; nested values and gaps keep their own marks. */
+export const EXAMPLE_PHRASE_PLUGIN: MarkdownInlinePlugin = {
+ pattern: /[✓✗][^;\n]+/g,
+ render: (match, key) => (
+
+ {renderNestedMarks(match[0])}
+
+ ),
+}
+
+/** Splits phrase text on the same patterns exampleMarkPlugins use, so a value or gap inside a phrase gets its own span. */
+function renderNestedMarks(text: string): ReactNode[] {
+ const [invented, gap] = exampleMarkPlugins.map((plugin) => plugin.pattern)
+ const nested = new RegExp(`${invented.source}|${gap.source}`, 'g')
+ const nodes: ReactNode[] = []
+ let last = 0
+ for (const m of text.matchAll(nested)) {
+ if (m.index > last) nodes.push(restoreStar(text.slice(last, m.index)))
+ nodes.push({restoreStar(m[0])})
+ last = m.index + m[0].length
+ }
+ if (last < text.length) nodes.push(restoreStar(text.slice(last)))
+ return nodes
+}
+
+type Segment = { text: string; className?: string }
+
+/** Cuts one line into plain stretches and ✓/✗ phrases so a phrase can be parsed whole, nested marks included. */
+function segmentLine(line: string): Segment[] {
+ const segments: Segment[] = []
+ let last = 0
+ for (const m of line.matchAll(EXAMPLE_PHRASE)) {
+ if (m.index > last) segments.push({ text: line.slice(last, m.index) })
+ segments.push({ text: m[0], className: m[1] === '✓' ? 'example-result' : 'example-rejection' })
+ last = m.index + m[0].length
+ }
+ if (last < line.length) segments.push({ text: line.slice(last) })
+ return segments
+}
+
+// SpecMarkdown takes no extra plugins, so this repeats its underscore guard to keep prose underscores from becoming emphasis.
+function ExampleMarkdown({ children, hasLineBreaks }: { children: string; hasLineBreaks?: boolean }) {
+ const itemLinkPlugins = useItemLinkPlugins()
+ const inlinePlugins = [...(itemLinkPlugins ?? []), ...exampleMarkPlugins, restorePlaceholders]
+ const text = guardProse(children.replace(/\r\n?/g, '\n'))
+ if (!new RegExp(EXAMPLE_PHRASE.source).test(text)) {
+ return (
+
+ {hasLineBreaks ? text.replace(/\n/g, ' \n') : text}
+
+ )
+ }
+ const inline = (content: string, key: string) => (
+
+ {content}
+
+ )
+ return (
+
+ {text.split('\n').map((line, lineIndex) => (
+
+ {lineIndex > 0 && (hasLineBreaks ?
: ' ')}
+ {segmentLine(line).map((segment, i) => {
+ const key = `${lineIndex}-${i}`
+ const [, lead, core, trail] = /^(\s*)([\s\S]*?)(\s*)$/.exec(segment.text)!
+ const body = core && (segment.className
+ ? {inline(core, key)}
+ : inline(core, key))
+ return {lead}{body}{trail}
+ })}
+
+ ))}
+
+ )
+}
+
+/** Underscore guard plus the invented-value star swap, for plain prose with no link destinations or code in it. */
+function guardPlain(text: string): string {
+ return text.replace(/_/g, '_\u200b').replaceAll(STAR, LITERAL_STAR_SENTINEL).replace(INVENTED_MARK, `$1${STAR}`)
+}
+
+/** Inline link destinations (`](...)`) and autolinks, whose text is a URL that the guards would corrupt. Every branch starts at a distinct character and the destination body always matches, so it cannot backtrack. */
+const LINK_TARGET = /\]\((?:<[^<>\n]*>|(?:[^\s()]|\([^\s()]*\))*)\)?|<[a-zA-Z][a-zA-Z0-9+.-]{1,31}:[^\s<>]*>/g
+
+/** Guards text that is not inside code, leaving link destinations and autolinks exactly as written. */
+function guardProse(text: string): string {
+ let out = ''
+ let last = 0
+ for (const m of text.matchAll(LINK_TARGET)) {
+ out += guardBesideVerbatim(text.slice(last, m.index), last > 0, true, guardPlain) + m[0]
+ last = m.index + m[0].length
+ }
+ return out + guardBesideVerbatim(text.slice(last), last > 0, false, guardPlain)
+}
+
+/** Guards one stretch of prose that sits beside verbatim text (code or a link target), padding the edges that text touches so the star pattern's start and end anchors don't treat it as the stretch's boundary. */
+function guardBesideVerbatim(text: string, afterVerbatim: boolean, beforeVerbatim: boolean, guard: (text: string) => string): string {
+ const guarded = guard(`${afterVerbatim ? '\u0001' : ''}${text}${beforeVerbatim ? '\u0001' : ''}`)
+ return guarded.slice(afterVerbatim ? 1 : 0, beforeVerbatim ? -1 : undefined)
+}
+
+/** Whether the character at `index` is escaped by an odd number of backslashes right before it. */
+function isBackslashEscaped(text: string, index: number): boolean {
+ let count = 0
+ while (index - count > 0 && text[index - count - 1] === '\\') count++
+ return count % 2 === 1
+}
+
+/**
+ * Guards one paragraph, leaving its inline code spans exactly as written. A run of N backticks opens a span
+ * that the next run of exactly N closes; an unmatched run is literal. A backtick outside code that follows an
+ * odd number of backslashes is a literal character, so it neither opens a span nor shortens a run it closes;
+ * inside a span backslashes are literal.
+ */
+function guardParagraph(paragraph: string): string {
+ const runs = [...paragraph.matchAll(/`+/g)].map((m) => ({ start: m.index, length: m[0].length }))
+ const runsByLength = new Map()
+ runs.forEach((run, i) => {
+ const indexes = runsByLength.get(run.length)
+ if (indexes) indexes.push(i)
+ else runsByLength.set(run.length, [i])
+ })
+ const scanned = new Map()
+ // opener positions only grow, so each length's list is walked once overall
+ const nextRunOfLength = (length: number, after: number): number => {
+ const indexes = runsByLength.get(length)
+ if (!indexes) return -1
+ let at = scanned.get(length) ?? 0
+ while (at < indexes.length && indexes[at] <= after) at++
+ scanned.set(length, at)
+ return at < indexes.length ? indexes[at] : -1
+ }
+ let out = ''
+ let cursor = 0
+ let i = 0
+ while (i < runs.length) {
+ const escaped = isBackslashEscaped(paragraph, runs[i].start) ? 1 : 0
+ const openStart = runs[i].start + escaped
+ const openLength = runs[i].length - escaped
+ const closer = openLength > 0 ? nextRunOfLength(openLength, i) : -1
+ if (closer === -1) {
+ i++
+ continue
+ }
+ const spanEnd = runs[closer].start + runs[closer].length
+ out += guardBesideVerbatim(paragraph.slice(cursor, openStart), cursor > 0, true, guardProse) + paragraph.slice(openStart, spanEnd)
+ cursor = spanEnd
+ i = closer + 1
+ }
+ return out + guardBesideVerbatim(paragraph.slice(cursor), cursor > 0, false, guardProse)
+}
+
+/** Guards prose outside code; a code span never reaches across a blank line. */
+function guardProseOutsideCode(text: string): string {
+ return text
+ .split(/(\n[ \t]*\n)/)
+ .map((part, i) => (i % 2 === 1 ? part : guardParagraph(part)))
+ .join('')
+}
+
+/** A fence line, including one that opens or closes inside blockquote and list-item markers (`> `, `- `, `1. `, nested or mixed). */
+const FENCE_LINE = /^[ \t]*(?:>[ \t]*|(?:[-*+]|\d{1,9}[.)])[ \t]+)*(`{3,}|~{3,})(.*)$/
+
+/** Normalizes line endings, then applies the guards to everything except fenced code blocks and inline code spans, which pass through as written. */
+function prepareDetailsBody(body: string): string {
+ const lines = body.replace(/\r\n?/g, '\n').match(/[^\n]*\n|[^\n]+/g) ?? []
+ let out = ''
+ let prose = ''
+ let fence: { char: string; length: number } | null = null
+ for (const line of lines) {
+ const content = line.replace(/\n$/, '')
+ const marker = FENCE_LINE.exec(content)
+ if (fence) {
+ out += line
+ if (marker && marker[1][0] === fence.char && marker[1].length >= fence.length && marker[2].trim() === '') fence = null
+ continue
+ }
+ if (marker && !(marker[1][0] === '`' && marker[2].includes('`'))) {
+ out += guardProseOutsideCode(prose) + line
+ prose = ''
+ fence = { char: marker[1][0], length: marker[1].length }
+ continue
+ }
+ prose += line
+ }
+ return out + guardProseOutsideCode(prose)
+}
+
+/** The details body as one block of Markdown, so tables, lists and code blocks keep their structure. */
+function ExampleDetailsBody({ children }: { children: string }) {
+ const itemLinkPlugins = useItemLinkPlugins()
+ const inlinePlugins = [...(itemLinkPlugins ?? []), EXAMPLE_PHRASE_PLUGIN, ...exampleMarkPlugins, restorePlaceholders]
+ return (
+
+ {prepareDetailsBody(children)}
+
+ )
+}
+
+function ExamplePane({ label, className, children }: { label: string; className?: string; children: ReactNode }) {
+ return (
+
+ )
+}
+
+/** A scenario's worked example: the data-state panes (records before, steps, records after) when it has them, then its titled details pane. */
+export function ScenarioExampleView({ example }: { example: ScenarioExample }) {
+ const { before, steps, after, details } = example
+ return (
+
+ {before !== undefined && steps !== undefined && after !== undefined && (
+
+
+ {before}
+
+
+
+ {steps.map((step, i) => (
+ - {step}
+ ))}
+
+
+
+ {after}
+
+
+ )}
+ {details &&
}
+
+ )
+}
+
+function ExampleDetails({ details }: { details: ScenarioDetails }) {
+ return (
+
+ {details.body}
+
+ )
+}
diff --git a/skills/writing-specs/interactive/app/src/views/scenario-focus-dialog.tsx b/skills/writing-specs/interactive/app/src/views/scenario-focus-dialog.tsx
index 0828fb1c..6524bccc 100644
--- a/skills/writing-specs/interactive/app/src/views/scenario-focus-dialog.tsx
+++ b/skills/writing-specs/interactive/app/src/views/scenario-focus-dialog.tsx
@@ -4,11 +4,19 @@ import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'
import { HStack, VStack } from '@astryxdesign/core/Layout'
import { Text } from '@astryxdesign/core/Text'
import type { Scenario } from '../spec-types'
-import { isScenarioUndecided } from '../review-state'
-import { Clause, CommentField, DecisionCell, FlagToggle, OutcomeCell, type Decision } from './scenarios-view'
+import { isScenarioUndecided, scenarioThen } from '../review-state'
+import { Clause, CommentField, DecisionCell, FlagToggle, OutcomeCell, Stage2Decision, type Decision } from './scenarios-view'
+import { ScenarioExampleView } from './scenario-example'
import { ClaudeField, Field } from './shared'
import type { ViewProps } from './view-props'
+/** Every scenario under the behavior that owns `startScenarioId`, in spec order, whatever its decision or stage. */
+function browseQueue(props: ViewProps, startScenarioId: string): string[] {
+ const { spec } = props
+ const behavior = spec.scenarios.find((s) => s.id === startScenarioId)?.behavior
+ return spec.scenarios.filter((s) => s.behavior === behavior).map((s) => s.id)
+}
+
function undecidedQueue(props: ViewProps, behaviorId?: string): string[] {
const { spec, state } = props
const behaviors = behaviorId ? spec.behaviors.filter((b) => b.id === behaviorId) : spec.behaviors
@@ -26,32 +34,45 @@ function ownsArrowKeys(target: EventTarget | null): boolean {
}
function FocusedScenario({ s, props, onDecided }: { s: Scenario; props: ViewProps; onDecided: (decision: Decision) => void }) {
+ const isStage2 = props.spec.stage === 2
return (
-
-
-
+ {isStage2 ? (
+
+ ) : (
+
+
+
+ )}
+ {s.example ? : null}
{s.explanation}
-
+ {isStage2 ? : }
-
+ {isStage2 ? null : }
)
}
-/** Walks the scenarios that were undecided when it opened, one at a time; the list is fixed so a scenario stays put while its reason is typed. */
-export function ScenarioFocusDialog({ isOpen, onClose, behaviorId, props }: { isOpen: boolean; onClose: () => void; behaviorId?: string; props: ViewProps }) {
+type FocusDialogProps = { onClose: () => void; behaviorId?: string; startScenarioId?: string; props: ViewProps }
+
+/**
+ * Walks scenarios one at a time from a list fixed when it opens, so a scenario stays put while its reason is typed.
+ * Without `startScenarioId` the list is the scenarios undecided at that moment; with it, every scenario under that
+ * scenario's behavior, opened at that scenario.
+ */
+export function ScenarioFocusDialog({ isOpen, ...rest }: FocusDialogProps & { isOpen: boolean }) {
if (!isOpen) return null
- return
+ return
}
-function OpenFocusDialog({ onClose, behaviorId, props }: { onClose: () => void; behaviorId?: string; props: ViewProps }) {
+function OpenFocusDialog({ onClose, behaviorId, startScenarioId, props }: FocusDialogProps) {
const { spec } = props
- const [queue] = useState(() => undecidedQueue(props, behaviorId))
- const [index, setIndex] = useState(0)
+ const isBrowsing = startScenarioId !== undefined
+ const [queue] = useState(() => (startScenarioId ? browseQueue(props, startScenarioId) : undecidedQueue(props, behaviorId)))
+ const [index, setIndex] = useState(() => (startScenarioId ? Math.max(0, queue.indexOf(startScenarioId)) : 0))
const isDone = index >= queue.length
const scenario = isDone ? undefined : spec.scenarios.find((s) => s.id === queue[index])
const behavior = scenario ? spec.behaviors.find((b) => b.id === scenario.behavior) : undefined
@@ -70,14 +91,15 @@ function OpenFocusDialog({ onClose, behaviorId, props }: { onClose: () => void;
return () => window.removeEventListener('keydown', onKey)
})
- const title = scenario ? `${scenario.id} · ${behavior?.rule ?? scenario.behavior}` : 'Undecided scenarios'
+ const hasExample = queue.some((id) => spec.scenarios.find((s) => s.id === id)?.example)
+ const title = scenario ? `${scenario.id} · ${behavior?.rule ?? scenario.behavior}` : isBrowsing ? 'Scenarios' : 'Undecided scenarios'
const subtitle = isDone ? 'End of the list' : `Scenario ${index + 1} of ${queue.length}`
return (
-