@meistrari/tela-build 1.66.0 → 1.67.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.
@@ -0,0 +1,79 @@
1
+ <script setup lang="ts">
2
+ import { renderMermaidSVG } from 'beautiful-mermaid'
3
+
4
+ const props = withDefaults(defineProps<{
5
+ content: string
6
+ label?: string
7
+ errorLabel?: string
8
+ }>(), {
9
+ label: 'Flowchart',
10
+ errorLabel: 'Flowchart could not be rendered',
11
+ })
12
+
13
+ const result = computed(() => {
14
+ try {
15
+ const svg = renderMermaidSVG(props.content, {
16
+ bg: DT.colors.background.DEFAULT,
17
+ fg: DT.colors.text.primary,
18
+ line: DT.colors.border.strong,
19
+ accent: DT.colors.text.secondary,
20
+ muted: DT.colors.text.secondary,
21
+ surface: DT.colors.background.subtle,
22
+ border: DT.colors.border.DEFAULT,
23
+ font: 'Inter Variable',
24
+ padding: 32,
25
+ transparent: true,
26
+ }).replace(/\s*@import\s+url\([^)]*\);\s*/u, '')
27
+
28
+ return { svg, error: '' }
29
+ }
30
+ catch (error) {
31
+ return {
32
+ svg: '',
33
+ error: error instanceof Error ? error.message : props.errorLabel,
34
+ }
35
+ }
36
+ })
37
+ </script>
38
+
39
+ <template>
40
+ <figure
41
+ flex="~ col" gap-12px
42
+ my-8px p-24px overflow-hidden
43
+ rounded-10px border-0.5px border bg
44
+ >
45
+ <div
46
+ v-if="result.svg"
47
+ role="img"
48
+ :aria-label="label"
49
+ w-full overflow-x-auto
50
+ data-rfc-flowchart
51
+ v-html="result.svg"
52
+ />
53
+
54
+ <div v-else flex="~ col" gap-12px>
55
+ <p body-12-medium text-secondary>
56
+ {{ errorLabel }}
57
+ </p>
58
+ <pre overflow-x-auto rounded-8px bg p-16px body-12-regular text-primary><code>{{ content }}</code></pre>
59
+ <p body-12-regular text-secondary>
60
+ {{ result.error }}
61
+ </p>
62
+ </div>
63
+
64
+ <figcaption body-12-medium text-primary>
65
+ {{ label }}
66
+ </figcaption>
67
+ </figure>
68
+ </template>
69
+
70
+ <style scoped>
71
+ [data-rfc-flowchart] :deep(svg) {
72
+ display: block;
73
+ width: auto;
74
+ min-width: 100%;
75
+ max-width: none;
76
+ height: auto;
77
+ margin: 0 auto;
78
+ }
79
+ </style>
@@ -0,0 +1,35 @@
1
+ <script setup lang="ts">
2
+ const props = withDefaults(defineProps<{
3
+ as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
4
+ id?: string
5
+ }>(), {
6
+ as: 'h2',
7
+ })
8
+
9
+ const isAnchored = computed(() => !!props.id && props.as !== 'h1')
10
+
11
+ function scrollToSelf(event: MouseEvent) {
12
+ const target = event.currentTarget instanceof HTMLElement ? event.currentTarget.closest(props.as) : null
13
+
14
+ if (!target)
15
+ return
16
+
17
+ event.preventDefault()
18
+ target.scrollIntoView({ behavior: 'smooth', block: 'start' })
19
+ history.replaceState(history.state, '', `#${props.id}`)
20
+ }
21
+ </script>
22
+
23
+ <template>
24
+ <component :is="as" :id="id">
25
+ <a
26
+ v-if="isAnchored"
27
+ :href="`#${id}`"
28
+ class="tela-rfc-heading-anchor"
29
+ @click="scrollToSelf"
30
+ >
31
+ <slot />
32
+ </a>
33
+ <slot v-else />
34
+ </component>
35
+ </template>
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { flattenRfcToc, parseRfcMarkdown } from './rfc-markdown'
4
+
5
+ describe('parseRfcMarkdown', () => {
6
+ it('extracts the title and a nested table of contents', async () => {
7
+ const document = await parseRfcMarkdown(`# Stateful workflow runs
8
+
9
+ ## Context
10
+
11
+ Introductory copy.
12
+
13
+ ### Current behavior
14
+
15
+ More detail.
16
+
17
+ ## Proposal
18
+ `)
19
+
20
+ expect(document.title).toBe('Stateful workflow runs')
21
+ expect(document.toc).toEqual([
22
+ {
23
+ id: 'context',
24
+ depth: 2,
25
+ text: 'Context',
26
+ children: [
27
+ {
28
+ id: 'context-current-behavior',
29
+ depth: 3,
30
+ text: 'Current behavior',
31
+ },
32
+ ],
33
+ },
34
+ {
35
+ id: 'proposal',
36
+ depth: 2,
37
+ text: 'Proposal',
38
+ },
39
+ ])
40
+
41
+ expect(flattenRfcToc(document.toc).map(link => link.text)).toEqual([
42
+ 'Context',
43
+ 'Current behavior',
44
+ 'Proposal',
45
+ ])
46
+ })
47
+
48
+ it('keeps ASCII illustrations and promotes Mermaid fences to flowcharts', async () => {
49
+ const document = await parseRfcMarkdown(`# Diagrams
50
+
51
+ \`\`\`ascii
52
+ +-------+ +--------+
53
+ | Draft | --> | Review |
54
+ +-------+ +--------+
55
+ \`\`\`
56
+
57
+ \`\`\`mermaid
58
+ flowchart LR
59
+ Draft --> Review
60
+ \`\`\`
61
+ `)
62
+
63
+ expect(document.tree.nodes).toContainEqual([
64
+ 'pre',
65
+ { language: 'ascii' },
66
+ [
67
+ 'code',
68
+ { class: 'language-ascii' },
69
+ '+-------+ +--------+\n| Draft | --> | Review |\n+-------+ +--------+',
70
+ ],
71
+ ])
72
+ expect(document.tree.nodes).toContainEqual([
73
+ 'mermaid',
74
+ { content: 'flowchart LR\n Draft --> Review\n' },
75
+ ])
76
+ })
77
+
78
+ it('treats raw HTML as text and removes custom component tags', async () => {
79
+ const document = await parseRfcMarkdown(`# Safe input
80
+
81
+ <script>alert('nope')</script>
82
+
83
+ ::unsafe-component{onload="alert('nope')"}
84
+ Hidden content
85
+ ::
86
+ `)
87
+
88
+ expect(document.tree.nodes).toContainEqual([
89
+ 'p',
90
+ {},
91
+ "<script>alert('nope')</script>",
92
+ ])
93
+ expect(document.tree.nodes.some(node => Array.isArray(node) && node[0] === 'unsafe-component')).toBe(false)
94
+ })
95
+ })
@@ -0,0 +1,92 @@
1
+ import type { ComarkTree } from 'comark'
2
+ import type { TocLink } from 'comark/plugins/toc'
3
+
4
+ import { parse } from 'comark'
5
+ import mermaid from 'comark/plugins/mermaid'
6
+ import security from 'comark/plugins/security'
7
+ import toc from 'comark/plugins/toc'
8
+ import { textContent } from 'comark/utils'
9
+
10
+ const allowedTags = [
11
+ 'a',
12
+ 'blockquote',
13
+ 'br',
14
+ 'code',
15
+ 'del',
16
+ 'em',
17
+ 'h1',
18
+ 'h2',
19
+ 'h3',
20
+ 'h4',
21
+ 'h5',
22
+ 'h6',
23
+ 'hr',
24
+ 'img',
25
+ 'input',
26
+ 'li',
27
+ 'mermaid',
28
+ 'ol',
29
+ 'p',
30
+ 'pre',
31
+ 's',
32
+ 'strong',
33
+ 'table',
34
+ 'tbody',
35
+ 'td',
36
+ 'th',
37
+ 'thead',
38
+ 'tr',
39
+ 'ul',
40
+ ]
41
+
42
+ const rfcPlugins = [
43
+ mermaid(),
44
+ toc({ depth: 3, searchDepth: 3 }),
45
+ security({
46
+ allowedTags,
47
+ allowedProtocols: ['http', 'https', 'mailto'],
48
+ allowDataImages: false,
49
+ }),
50
+ ] as const
51
+
52
+ export interface ParsedRfcDocument {
53
+ tree: ComarkTree
54
+ title: string
55
+ toc: TocLink[]
56
+ }
57
+
58
+ export interface FlatRfcTocLink extends Omit<TocLink, 'children'> {
59
+ children?: TocLink[]
60
+ }
61
+
62
+ function documentTitle(tree: ComarkTree): string {
63
+ const titleHeading = tree.nodes.find(node => Array.isArray(node) && node[0] === 'h1')
64
+ const headingText = titleHeading ? textContent(titleHeading).trim() : ''
65
+
66
+ if (headingText)
67
+ return headingText
68
+
69
+ const frontmatterTitle = tree.frontmatter.title
70
+
71
+ return typeof frontmatterTitle === 'string' ? frontmatterTitle.trim() : ''
72
+ }
73
+
74
+ export async function parseRfcMarkdown(markdown: string): Promise<ParsedRfcDocument> {
75
+ const tree = await parse(markdown, {
76
+ html: false,
77
+ plugins: rfcPlugins,
78
+ })
79
+
80
+ return {
81
+ tree,
82
+ title: documentTitle(tree),
83
+ toc: tree.meta.toc.links,
84
+ }
85
+ }
86
+
87
+ export function flattenRfcToc(links: TocLink[]): FlatRfcTocLink[] {
88
+ return links.flatMap(link => [
89
+ link,
90
+ ...flattenRfcToc(link.children ?? []),
91
+ ])
92
+ }
@@ -0,0 +1,46 @@
1
+ <script setup lang="ts">
2
+ import type { ComarkElement } from 'comark'
3
+
4
+ const props = withDefaults(defineProps<{
5
+ code?: string
6
+ language?: string
7
+ // eslint-disable-next-line vue/prop-name-casing -- Comark passes the element node as `__node`
8
+ __node?: ComarkElement
9
+ }>(), {
10
+ code: '',
11
+ language: '',
12
+ })
13
+
14
+ function nodeText(node: unknown): string {
15
+ if (typeof node === 'string')
16
+ return node
17
+
18
+ if (!Array.isArray(node))
19
+ return ''
20
+
21
+ return node.slice(2).map(nodeText).join('')
22
+ }
23
+
24
+ function nodeLanguage(node: ComarkElement | undefined): string {
25
+ if (!node)
26
+ return ''
27
+
28
+ const [, nodeProps, ...children] = node
29
+
30
+ if (typeof nodeProps?.language === 'string')
31
+ return nodeProps.language
32
+
33
+ const codeChild = children.find((child): child is ComarkElement => Array.isArray(child) && child[0] === 'code')
34
+ const codeClass = codeChild?.[1]?.class
35
+ const match = typeof codeClass === 'string' ? codeClass.match(/language-(\S+)/) : null
36
+
37
+ return match?.[1] ?? ''
38
+ }
39
+
40
+ const code = computed(() => (props.code || nodeText(props.__node)).trimEnd())
41
+ const language = computed(() => props.language || nodeLanguage(props.__node) || 'text')
42
+ </script>
43
+
44
+ <template>
45
+ <TelaCodeBlock :code="code" :language="language" class="my-16px" />
46
+ </template>
@@ -0,0 +1,131 @@
1
+ import { Meta } from '@storybook/blocks';
2
+
3
+ <Meta title="Layout/RFC" tags={['new']} />
4
+
5
+ # TelaRfc
6
+
7
+ A full-page template for reading technical RFCs. Pass one plain Markdown string and the component renders a centered document column with a fixed table of contents generated from the document headings.
8
+
9
+ Fenced code blocks render through `TelaCodeBlock` (line numbers and copy action); `ascii` blocks stay plain text so illustrations are preserved exactly. Fenced `mermaid` blocks render as flowcharts and fall back to their source when the diagram is invalid.
10
+
11
+ ## Intent
12
+
13
+ `TelaRfc` is for an engineer or reviewer who needs to understand a proposal, keep their place in a long document, and inspect architecture diagrams without leaving the reading flow. It should feel precise and calm, like a well-typeset technical memo.
14
+
15
+ - **Signature:** the table of contents stays fixed at the right of the viewport, tracks the active heading, and surfaces the document title once it scrolls away.
16
+ - **Depth:** borders-only, matching a technical document rather than a card dashboard.
17
+ - **Typography:** the same prose scale as the Tela Build docs site — `heading-h2/h3/h4-semibold` titles, `body-16-regular` secondary prose — on a centered 760px reading column.
18
+ - **Spacing:** 32px before each H2, 28px before H3, and 12px between paragraphs and list groups.
19
+
20
+ ## Basic usage
21
+
22
+ ```vue
23
+ <script setup lang="ts">
24
+ const markdown = `# Stateful workflow runs
25
+
26
+ This RFC proposes durable state between workflow steps.
27
+
28
+ ## Context
29
+
30
+ Today every step reconstructs the state it needs.
31
+
32
+ ### Current flow
33
+
34
+ \`\`\`ascii
35
+ +---------+ +---------+ +--------+
36
+ | Trigger | --> | Resolve | --> | Action |
37
+ +---------+ +---------+ +--------+
38
+ \`\`\`
39
+
40
+ ## Proposal
41
+
42
+ Persist a versioned state envelope after each successful step.
43
+
44
+ \`\`\`mermaid
45
+ flowchart LR
46
+ Trigger --> Resolve
47
+ Resolve --> Persist
48
+ Persist --> Action
49
+ \`\`\`
50
+
51
+ ## Alternatives considered
52
+
53
+ - Rebuild state for every step
54
+ - Keep state only in worker memory
55
+
56
+ ## Rollout
57
+
58
+ 1. Add the state envelope behind a flag
59
+ 2. Migrate internal workflows
60
+ 3. Enable it for new workflows
61
+ `
62
+ </script>
63
+
64
+ <template>
65
+ <TelaRfc :markdown="markdown" />
66
+ </template>
67
+ ```
68
+
69
+ ## Markdown contract
70
+
71
+ Use one H1 for the RFC title. H2, H3, and H4 headings populate the table of contents; deeper headings remain in the document but do not make the navigation unnecessarily dense. Consumers do not render `TelaTableOfContents` separately: `TelaRfc` owns it and derives its items from the Markdown.
72
+
73
+ Standard CommonMark and GFM content is supported, including:
74
+
75
+ - paragraphs, emphasis, links, images, and blockquotes
76
+ - ordered, unordered, nested, and task lists
77
+ - tables and horizontal rules
78
+ - inline code and fenced code blocks
79
+ - `ascii` fenced blocks for monospaced illustrations
80
+ - `mermaid` fenced blocks for flowcharts
81
+ - optional YAML frontmatter, which is parsed as document metadata and not rendered as prose
82
+
83
+ Raw HTML is displayed as text. Custom Markdown component tags are removed. Links are limited to relative URLs plus HTTP, HTTPS, and mailto protocols, and data-URL images are disabled.
84
+
85
+ ## Anatomy
86
+
87
+ ```
88
+ TelaRfc ← full-viewport scroll container
89
+ └─ centered reading column ← 760px, mx-auto
90
+ ├─ TelaTableOfContents ← fixed at the right, built in
91
+ └─ article
92
+ └─ parsed Markdown
93
+ ├─ headings (H2+ wrapped in a `#` anchor link)
94
+ ├─ prose, lists, tables
95
+ ├─ code blocks → TelaCodeBlock
96
+ └─ TelaRfcFlowchart
97
+ ```
98
+
99
+ Below 1511px the table of contents is hidden and the document keeps the full reading column; headings remain addressable through their native fragment anchors.
100
+
101
+ ## Props
102
+
103
+ | Prop | Type | Default | Description |
104
+ | --- | --- | --- | --- |
105
+ | `markdown` | `string` | required | Plain Markdown source for the RFC. |
106
+ | `tocLabel` | `string` | `'On this page'` | Accessible label for the built-in table of contents. |
107
+ | `emptyLabel` | `string` | `'No RFC content'` | Empty-state copy. |
108
+ | `errorLabel` | `string` | `'Unable to render this RFC'` | Parser-error fallback copy. |
109
+
110
+ ## Slots
111
+
112
+ | Slot | Scope | Description |
113
+ | --- | --- | --- |
114
+ | `empty` | — | Replaces the default empty state. |
115
+
116
+ ## Behavior and accessibility
117
+
118
+ - The RFC shell is the only vertical scroll container and occupies `100dvh`.
119
+ - TOC links are native fragment links and heading targets have a scroll margin.
120
+ - H2–H6 headings wrap their text in a fragment link that reveals a `#` on hover and smooth-scrolls within the RFC container.
121
+ - The active H2–H4 section is exposed with `aria-current="location"`.
122
+ - Flowcharts expose `role="img"` with an accessible label and always retain a readable source fallback.
123
+ - Images and wide code blocks stay within the reading column and scroll horizontally when necessary.
124
+
125
+ ## Rules
126
+
127
+ 1. Pass the source Markdown unchanged; do not pre-render it to HTML.
128
+ 2. Use exactly one H1 when the document has a visible title.
129
+ 3. Use H2 for major RFC sections and H3/H4 for subsections so the generated TOC remains meaningful.
130
+ 4. Tag text diagrams as `ascii` and rendered flowcharts as `mermaid`.
131
+ 5. Keep primary RFC content in Markdown; the table of contents is derived automatically.
@@ -0,0 +1,329 @@
1
+ <script setup lang="ts">
2
+ import { ComarkRenderer } from '@comark/vue'
3
+
4
+ import RfcFlowchart from './rfc-flowchart.vue'
5
+ import RfcHeading from './rfc-heading.vue'
6
+ import RfcPre from './rfc-pre.vue'
7
+ import { flattenRfcToc, parseRfcMarkdown } from './rfc-markdown'
8
+ import type { ParsedRfcDocument } from './rfc-markdown'
9
+
10
+ const props = withDefaults(defineProps<{
11
+ markdown: string
12
+ tocLabel?: string
13
+ emptyLabel?: string
14
+ errorLabel?: string
15
+ }>(), {
16
+ tocLabel: 'On this page',
17
+ emptyLabel: 'No RFC content',
18
+ errorLabel: 'Unable to render this RFC',
19
+ })
20
+
21
+ defineSlots<{
22
+ empty: () => unknown
23
+ }>()
24
+
25
+ type HeadingTag = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
26
+
27
+ function heading(as: HeadingTag) {
28
+ return defineComponent({
29
+ inheritAttrs: false,
30
+ setup: (_, { attrs, slots }) => () => h(RfcHeading, { ...attrs, as }, slots),
31
+ })
32
+ }
33
+
34
+ const markdownComponents = {
35
+ a: 'a',
36
+ blockquote: 'blockquote',
37
+ br: 'br',
38
+ code: 'code',
39
+ del: 'del',
40
+ em: 'em',
41
+ h1: heading('h1'),
42
+ h2: heading('h2'),
43
+ h3: heading('h3'),
44
+ h4: heading('h4'),
45
+ h5: heading('h5'),
46
+ h6: heading('h6'),
47
+ hr: 'hr',
48
+ img: 'img',
49
+ input: 'input',
50
+ li: 'li',
51
+ mermaid: RfcFlowchart,
52
+ ol: 'ol',
53
+ p: 'p',
54
+ pre: RfcPre,
55
+ s: 's',
56
+ strong: 'strong',
57
+ table: 'table',
58
+ tbody: 'tbody',
59
+ td: 'td',
60
+ th: 'th',
61
+ thead: 'thead',
62
+ tr: 'tr',
63
+ ul: 'ul',
64
+ }
65
+
66
+ const scrollContainerRef = ref<HTMLElement | null>(null)
67
+ const articleRef = ref<HTMLElement | null>(null)
68
+ const parsedDocument = shallowRef<ParsedRfcDocument | null>(null)
69
+ const parseError = ref('')
70
+ const isParsing = ref(true)
71
+
72
+ const tocLinks = computed(() => parsedDocument.value?.toc ?? [])
73
+ const tocItems = computed(() => flattenRfcToc(tocLinks.value).map(link => ({
74
+ id: link.id,
75
+ text: link.text,
76
+ level: link.depth,
77
+ })))
78
+ const isEmpty = computed(() => !props.markdown.trim())
79
+
80
+ let parseRevision = 0
81
+
82
+ async function updateMarkdown(markdown: string) {
83
+ const revision = ++parseRevision
84
+
85
+ isParsing.value = parsedDocument.value === null
86
+ parseError.value = ''
87
+
88
+ try {
89
+ const document = await parseRfcMarkdown(markdown)
90
+
91
+ if (revision !== parseRevision)
92
+ return
93
+
94
+ parsedDocument.value = document
95
+ }
96
+ catch (error) {
97
+ if (revision !== parseRevision)
98
+ return
99
+
100
+ parseError.value = error instanceof Error ? error.message : props.errorLabel
101
+ }
102
+ finally {
103
+ if (revision === parseRevision)
104
+ isParsing.value = false
105
+ }
106
+ }
107
+
108
+ watch(() => props.markdown, updateMarkdown, { immediate: true })
109
+ </script>
110
+
111
+ <template>
112
+ <main
113
+ ref="scrollContainerRef"
114
+ data-tela-rfc
115
+ h-dvh w-full overflow-y-auto bg
116
+ >
117
+ <div
118
+ :aria-busy="isParsing"
119
+ class="mx-auto w-full max-w-760px px-20px pb-64px pt-24px min-[821px]:px-40px min-[821px]:pb-80px min-[821px]:pt-80px"
120
+ >
121
+ <TelaTableOfContents
122
+ v-if="tocItems.length"
123
+ :items="tocItems"
124
+ :label="tocLabel"
125
+ :content-root="articleRef"
126
+ :scroll-root="scrollContainerRef"
127
+ />
128
+
129
+ <article ref="articleRef" data-rfc-document min-w-0>
130
+ <div v-if="isParsing" role="status" flex="~ col" gap-16px aria-live="polite">
131
+ <TelaSkeleton h-36px w-72% rounded-8px />
132
+ <TelaSkeleton h-20px w-full rounded-4px />
133
+ <TelaSkeleton h-20px w-88% rounded-4px />
134
+ <TelaSkeleton h-220px w-full rounded-12px />
135
+ </div>
136
+
137
+ <slot v-else-if="isEmpty" name="empty">
138
+ <div py-80px text-center>
139
+ <p body-14-regular text-secondary>
140
+ {{ emptyLabel }}
141
+ </p>
142
+ </div>
143
+ </slot>
144
+
145
+ <div v-else-if="parseError" flex="~ col" gap-16px>
146
+ <p body-14-medium text-primary>
147
+ {{ errorLabel }}
148
+ </p>
149
+ <pre overflow-x-auto rounded-10px border-0.5px border bg p-20px body-14-regular text-primary><code>{{ markdown }}</code></pre>
150
+ </div>
151
+
152
+ <ComarkRenderer
153
+ v-else-if="parsedDocument"
154
+ :tree="parsedDocument.tree"
155
+ :components="markdownComponents"
156
+ />
157
+ </article>
158
+ </div>
159
+ </main>
160
+ </template>
161
+
162
+ <style scoped>
163
+ [data-rfc-document] :deep(h1),
164
+ [data-rfc-document] :deep(h2),
165
+ [data-rfc-document] :deep(h3),
166
+ [data-rfc-document] :deep(h4),
167
+ [data-rfc-document] :deep(h5),
168
+ [data-rfc-document] :deep(h6) {
169
+ --at-apply: 'text-primary';
170
+ scroll-margin-top: 24px;
171
+ }
172
+
173
+ [data-rfc-document] :deep(h1) {
174
+ --at-apply: 'heading-h2-semibold';
175
+ margin: 0 0 8px;
176
+ }
177
+
178
+ [data-rfc-document] :deep(h2) {
179
+ --at-apply: 'heading-h3-semibold';
180
+ margin: 32px 0 8px;
181
+ }
182
+
183
+ [data-rfc-document] :deep(h3) {
184
+ --at-apply: 'heading-h4-semibold';
185
+ margin: 28px 0 8px;
186
+ }
187
+
188
+ [data-rfc-document] :deep(h4),
189
+ [data-rfc-document] :deep(h5),
190
+ [data-rfc-document] :deep(h6) {
191
+ --at-apply: 'heading-h5-semibold';
192
+ margin: 24px 0 8px;
193
+ }
194
+
195
+ [data-rfc-document] :deep(.tela-rfc-heading-anchor) {
196
+ color: inherit;
197
+ font: inherit;
198
+ text-decoration: none;
199
+ }
200
+
201
+ [data-rfc-document] :deep(.tela-rfc-heading-anchor::after) {
202
+ --at-apply: 'text-secondary';
203
+ content: '#';
204
+ margin-left: 6px;
205
+ opacity: 0;
206
+ transition: opacity 150ms ease;
207
+ }
208
+
209
+ [data-rfc-document] :deep(.tela-rfc-heading-anchor:hover::after),
210
+ [data-rfc-document] :deep(.tela-rfc-heading-anchor:focus-visible::after) {
211
+ opacity: 1;
212
+ }
213
+
214
+ [data-rfc-document] :deep(p) {
215
+ --at-apply: 'body-16-regular leading-24px text-secondary';
216
+ margin: 0 0 12px;
217
+ }
218
+
219
+ [data-rfc-document] :deep(strong) {
220
+ --at-apply: 'font-580';
221
+ }
222
+
223
+ [data-rfc-document] :deep(a) {
224
+ --at-apply: 'text-primary';
225
+ text-decoration: underline;
226
+ text-underline-offset: 2px;
227
+ }
228
+
229
+ [data-rfc-document] :deep(a:focus-visible) {
230
+ outline: 2px solid currentColor;
231
+ outline-offset: 2px;
232
+ }
233
+
234
+ [data-rfc-document] :deep(ul),
235
+ [data-rfc-document] :deep(ol) {
236
+ margin: 12px 0 12px;
237
+ padding-left: 20px;
238
+ list-style: disc;
239
+ }
240
+
241
+ [data-rfc-document] :deep(p + ul),
242
+ [data-rfc-document] :deep(p + ol) {
243
+ margin-top: 0;
244
+ }
245
+
246
+ [data-rfc-document] :deep(p:has(+ ul)),
247
+ [data-rfc-document] :deep(p:has(+ ol)) {
248
+ margin-bottom: 0;
249
+ }
250
+
251
+ [data-rfc-document] :deep(ol) {
252
+ list-style: decimal;
253
+ }
254
+
255
+ [data-rfc-document] :deep(li) {
256
+ --at-apply: 'body-16-regular text-secondary';
257
+ margin: 8px 0;
258
+ }
259
+
260
+ [data-rfc-document] :deep(li > ul),
261
+ [data-rfc-document] :deep(li > ol) {
262
+ margin-top: 8px;
263
+ margin-bottom: 0;
264
+ }
265
+
266
+ [data-rfc-document] :deep(blockquote) {
267
+ --at-apply: 'text-secondary border';
268
+ margin: 24px 0;
269
+ padding-left: 16px;
270
+ border-left: 0.5px solid;
271
+ }
272
+
273
+ [data-rfc-document] :deep(blockquote p:last-child) {
274
+ margin-bottom: 0;
275
+ }
276
+
277
+ [data-rfc-document] :deep(code) {
278
+ --at-apply: 'bg-muted rounded-4px text-secondary';
279
+ font-family: 'Geist Mono Variable', monospace;
280
+ font-size: 12.5px;
281
+ padding: 0 4px;
282
+ }
283
+
284
+ [data-rfc-document] :deep(pre code) {
285
+ background: transparent;
286
+ border-radius: 0;
287
+ padding: 0;
288
+ font-size: inherit;
289
+ }
290
+
291
+ [data-rfc-document] :deep(hr) {
292
+ --at-apply: 'bg-border';
293
+ border: none;
294
+ height: 0.5px;
295
+ margin: 32px 0;
296
+ }
297
+
298
+ [data-rfc-document] :deep(table) {
299
+ width: 100%;
300
+ border-collapse: collapse;
301
+ margin: 16px 0;
302
+ }
303
+
304
+ [data-rfc-document] :deep(th) {
305
+ --at-apply: 'body-12-medium text-secondary border-b-0.5px border';
306
+ text-align: left;
307
+ padding: 8px;
308
+ }
309
+
310
+ [data-rfc-document] :deep(td) {
311
+ --at-apply: 'body-14-regular text-primary border-b-0.5px border';
312
+ padding: 8px;
313
+ }
314
+
315
+ [data-rfc-document] :deep(img) {
316
+ --at-apply: 'border';
317
+ display: block;
318
+ max-width: 100%;
319
+ height: auto;
320
+ margin: 24px auto;
321
+ border-width: 0.5px;
322
+ border-style: solid;
323
+ border-radius: 10px;
324
+ }
325
+
326
+ [data-rfc-document] :deep(input[type='checkbox']) {
327
+ margin-right: 8px;
328
+ }
329
+ </style>
@@ -0,0 +1,81 @@
1
+ import { Meta } from '@storybook/blocks';
2
+
3
+ <Meta title="Components/Table of Contents" tags={['new']} />
4
+
5
+ # TelaTableOfContents
6
+
7
+ A viewport-fixed in-page table of contents (the same one used by the Tela Build docs site) that tracks the current heading and scrolls to native fragment targets. It can discover headings from an article or receive an explicit item list, and it hides itself below 1511px.
8
+
9
+ ## Basic usage
10
+
11
+ By default it discovers H2 and H3 elements in the first `article`. Remount it when a client-side route changes so it discovers the new document.
12
+
13
+ ```vue
14
+ <article>
15
+ <h1 id="proposal">Proposal</h1>
16
+ <h2 id="context">Context</h2>
17
+ <h3 id="constraints">Constraints</h3>
18
+ </article>
19
+
20
+ <TelaTableOfContents :key="$route.path" />
21
+ ```
22
+
23
+ ## Supplied items and contained scrolling
24
+
25
+ Pass `items` when the headings are already known (for example from a parsed Markdown tree), `contentRoot` when the headings live in a specific element, and `scrollRoot` when that element scrolls inside the viewport.
26
+
27
+ ```vue
28
+ <script setup lang="ts">
29
+ const scrollRoot = ref<HTMLElement | null>(null)
30
+ const contentRoot = ref<HTMLElement | null>(null)
31
+
32
+ const items = [
33
+ { id: 'context', text: 'Context', level: 2 },
34
+ { id: 'constraints', text: 'Constraints', level: 3 },
35
+ { id: 'proposal', text: 'Proposal', level: 2 },
36
+ ]
37
+ </script>
38
+
39
+ <template>
40
+ <main ref="scrollRoot" h-dvh overflow-y-auto>
41
+ <div mx-auto max-w-760px>
42
+ <TelaTableOfContents
43
+ :items="items"
44
+ :content-root="contentRoot"
45
+ :scroll-root="scrollRoot"
46
+ />
47
+
48
+ <article ref="contentRoot">
49
+ <h2 id="context">Context</h2>
50
+ <h3 id="constraints">Constraints</h3>
51
+ <h2 id="proposal">Proposal</h2>
52
+ </article>
53
+ </div>
54
+ </main>
55
+ </template>
56
+ ```
57
+
58
+ ## Props
59
+
60
+ | Prop | Type | Default | Description |
61
+ | --- | --- | --- | --- |
62
+ | `items` | `{ id: string, text: string, level: number }[]` | discovered | Explicit ordered heading list. |
63
+ | `label` | `string` | `undefined` | Accessible navigation label. Falls back to the document title, then “Table of contents”. |
64
+ | `contentRoot` | `HTMLElement \| null` | first matching article | Element containing the target headings. |
65
+ | `scrollRoot` | `HTMLElement \| null` | viewport | Scroll container used for active-heading observation and scroll-to-top behavior. |
66
+ | `articleSelector` | `string` | `'article'` | Fallback selector used when `contentRoot` is not supplied. |
67
+ | `headingSelector` | `string` | `'h2[id], h3[id]'` | Headings discovered when `items` is not supplied. |
68
+ | `titleSelector` | `string` | `'h1[id]'` | Title shown above the list after the document title scrolls away. |
69
+
70
+ ## Events
71
+
72
+ | Event | Payload | Description |
73
+ | --- | --- | --- |
74
+ | `select` | `{ id, text, level }` | Emitted after a TOC item is selected. |
75
+
76
+ ## Behavior and accessibility
77
+
78
+ - Links retain native fragment URLs and expose the tracked section with `aria-current="location"`.
79
+ - Selecting a link scrolls smoothly and updates the URL hash without adding history entries.
80
+ - The title control leaves the tab order while hidden and returns to the top of the current scroll root.
81
+ - The navigation is hidden below 1511px; the document keeps its native heading anchors.
@@ -0,0 +1,235 @@
1
+ <script setup lang="ts">
2
+ interface TableOfContentsItem {
3
+ id: string
4
+ text: string
5
+ level: number
6
+ }
7
+
8
+ const props = withDefaults(defineProps<{
9
+ items?: TableOfContentsItem[]
10
+ label?: string
11
+ contentRoot?: HTMLElement | null
12
+ scrollRoot?: HTMLElement | null
13
+ articleSelector?: string
14
+ headingSelector?: string
15
+ titleSelector?: string
16
+ }>(), {
17
+ articleSelector: 'article',
18
+ headingSelector: 'h2[id], h3[id]',
19
+ titleSelector: 'h1[id]',
20
+ })
21
+
22
+ const emit = defineEmits<{
23
+ select: [item: TableOfContentsItem]
24
+ }>()
25
+
26
+ const discoveredItems = ref<TableOfContentsItem[]>([])
27
+ const activeId = ref('')
28
+ const pageTitle = ref('')
29
+ const showPageTitle = ref(false)
30
+
31
+ const displayedItems = computed(() => props.items ?? discoveredItems.value)
32
+ const ariaLabel = computed(() => props.label || pageTitle.value || 'Table of contents')
33
+ const minimumLevel = computed(() => Math.min(...displayedItems.value.map(item => item.level)))
34
+
35
+ const indentClasses = ['', 'pl-12px', 'pl-24px', 'pl-36px']
36
+
37
+ let headingObserver: IntersectionObserver | undefined
38
+ let titleObserver: IntersectionObserver | undefined
39
+ let manualClickId: string | undefined
40
+ let manualClickTimeout: ReturnType<typeof setTimeout> | undefined
41
+ let retryTimeout: ReturnType<typeof setTimeout> | undefined
42
+
43
+ function getContentRoot() {
44
+ return props.contentRoot ?? document.querySelector<HTMLElement>(props.articleSelector)
45
+ }
46
+
47
+ function extractItems(contentRoot: HTMLElement) {
48
+ return [...contentRoot.querySelectorAll<HTMLElement>(props.headingSelector)]
49
+ .map(element => ({
50
+ id: element.id,
51
+ text: element.textContent?.trim() ?? '',
52
+ level: Number(element.tagName[1]),
53
+ }))
54
+ .filter(item => item.id && item.text)
55
+ }
56
+
57
+ function getHeadingElement(contentRoot: HTMLElement, id: string) {
58
+ return [...contentRoot.querySelectorAll<HTMLElement>('[id]')].find(element => element.id === id)
59
+ }
60
+
61
+ function updateActiveHeading(contentRoot: HTMLElement) {
62
+ if (manualClickId)
63
+ return
64
+
65
+ const rootTop = props.scrollRoot?.getBoundingClientRect().top ?? 0
66
+ const activationTop = rootTop + window.innerHeight * 0.2
67
+ const headingElements = displayedItems.value
68
+ .map(item => getHeadingElement(contentRoot, item.id))
69
+ .filter((element): element is HTMLElement => !!element)
70
+ const current = headingElements
71
+ .filter(element => element.getBoundingClientRect().top <= activationTop)
72
+ .at(-1)
73
+
74
+ activeId.value = current?.id || headingElements[0]?.id || ''
75
+ }
76
+
77
+ function disconnectObservers() {
78
+ headingObserver?.disconnect()
79
+ titleObserver?.disconnect()
80
+ headingObserver = undefined
81
+ titleObserver = undefined
82
+ }
83
+
84
+ function observe(attempts = 0) {
85
+ clearTimeout(retryTimeout)
86
+ disconnectObservers()
87
+
88
+ const contentRoot = getContentRoot()
89
+
90
+ if (!contentRoot) {
91
+ if (attempts < 20)
92
+ retryTimeout = setTimeout(() => observe(attempts + 1), 100)
93
+
94
+ return
95
+ }
96
+
97
+ const nextItems = props.items ?? extractItems(contentRoot)
98
+
99
+ if (props.items === undefined)
100
+ discoveredItems.value = nextItems
101
+
102
+ const headingElements = nextItems
103
+ .map(item => getHeadingElement(contentRoot, item.id))
104
+ .filter((element): element is HTMLElement => !!element)
105
+
106
+ if (!nextItems.length || headingElements.length < nextItems.length) {
107
+ if (attempts < 20)
108
+ retryTimeout = setTimeout(() => observe(attempts + 1), 100)
109
+
110
+ if (!headingElements.length)
111
+ return
112
+ }
113
+
114
+ activeId.value = nextItems[0]?.id ?? ''
115
+
116
+ if (typeof IntersectionObserver === 'undefined')
117
+ return
118
+
119
+ headingObserver = new IntersectionObserver(() => updateActiveHeading(contentRoot), {
120
+ root: props.scrollRoot ?? null,
121
+ rootMargin: '-20% 0px -70% 0px',
122
+ threshold: [0, 1],
123
+ })
124
+
125
+ for (const element of headingElements)
126
+ headingObserver.observe(element)
127
+
128
+ updateActiveHeading(contentRoot)
129
+
130
+ const title = contentRoot.querySelector<HTMLElement>(props.titleSelector)
131
+
132
+ pageTitle.value = title?.textContent?.trim() ?? ''
133
+ showPageTitle.value = false
134
+
135
+ if (title) {
136
+ titleObserver = new IntersectionObserver(([entry]) => {
137
+ const rootTop = props.scrollRoot?.getBoundingClientRect().top ?? 0
138
+
139
+ showPageTitle.value = !!entry && !entry.isIntersecting && entry.boundingClientRect.top < rootTop
140
+ }, {
141
+ root: props.scrollRoot ?? null,
142
+ threshold: 0,
143
+ })
144
+ titleObserver.observe(title)
145
+ }
146
+ }
147
+
148
+ function itemClass(item: TableOfContentsItem) {
149
+ const depth = Math.max(0, item.level - minimumLevel.value)
150
+
151
+ return [
152
+ indentClasses[Math.min(depth, indentClasses.length - 1)],
153
+ activeId.value === item.id ? 'text-primary' : 'text-tertiary hover:text-secondary',
154
+ ]
155
+ }
156
+
157
+ function scrollToHeading(item: TableOfContentsItem) {
158
+ const contentRoot = getContentRoot()
159
+ const element = contentRoot ? getHeadingElement(contentRoot, item.id) : undefined
160
+
161
+ if (!element)
162
+ return
163
+
164
+ clearTimeout(manualClickTimeout)
165
+ manualClickId = item.id
166
+ activeId.value = item.id
167
+
168
+ element.scrollIntoView({ behavior: 'smooth', block: 'start' })
169
+ history.replaceState(history.state, '', `#${item.id}`)
170
+ emit('select', item)
171
+
172
+ manualClickTimeout = setTimeout(() => {
173
+ manualClickId = undefined
174
+ }, 1000)
175
+ }
176
+
177
+ function scrollToTop() {
178
+ (props.scrollRoot ?? window).scrollTo({ top: 0, behavior: 'smooth' })
179
+ }
180
+
181
+ function cleanup() {
182
+ disconnectObservers()
183
+ clearTimeout(manualClickTimeout)
184
+ clearTimeout(retryTimeout)
185
+ manualClickId = undefined
186
+ }
187
+
188
+ onMounted(() => observe())
189
+ onBeforeUnmount(cleanup)
190
+
191
+ watch([
192
+ () => props.items,
193
+ () => props.contentRoot,
194
+ () => props.scrollRoot,
195
+ ], async () => {
196
+ await nextTick()
197
+ observe()
198
+ }, { flush: 'post' })
199
+ </script>
200
+
201
+ <template>
202
+ <aside
203
+ v-if="displayedItems.length"
204
+ class="fixed top-0 w-340px h-screen overflow-y-auto no-scrollbar py-80px pr-48px pl-16px flex flex-col gap-12px select-none [@media(max-width:1510px)]:hidden right-[calc(60px+var(--scrollbar-width,0px))]"
205
+ >
206
+ <Motion
207
+ as="button"
208
+ type="button"
209
+ class="text-left body-14-medium text-primary truncate shrink-0 hover:text-secondary"
210
+ :initial="false"
211
+ :animate="{ opacity: showPageTitle ? 1 : 0 }"
212
+ :transition="{ ease: 'easeOut', duration: 0.15 }"
213
+ :style="{ pointerEvents: showPageTitle ? 'auto' : 'none' }"
214
+ :tabindex="showPageTitle ? 0 : -1"
215
+ :aria-hidden="!showPageTitle"
216
+ @click="scrollToTop"
217
+ >
218
+ {{ pageTitle }}
219
+ </Motion>
220
+
221
+ <nav :aria-label="ariaLabel" class="flex flex-col gap-6px">
222
+ <a
223
+ v-for="item in displayedItems"
224
+ :key="item.id"
225
+ :href="`#${item.id}`"
226
+ class="body-14-regular break-words transition-colors duration-150"
227
+ :class="itemClass(item)"
228
+ :aria-current="activeId === item.id ? 'location' : undefined"
229
+ @click.prevent="scrollToHeading(item)"
230
+ >
231
+ {{ item.text }}
232
+ </a>
233
+ </nav>
234
+ </aside>
235
+ </template>
@@ -187,11 +187,14 @@ Before building a new page from scratch, check for an existing **layout template
187
187
  | --- | --- | --- |
188
188
  | `TelaHome` (`home.vue`) | Root index / list / dashboard pages with a sidebar rail. | `components/tela/home/home.mdx` |
189
189
  | `TelaDetails` (`details.vue`) | Full-screen detail / record pages and fullscreen modals. | `components/tela/details/details.mdx` |
190
+ | `TelaRfc` (`rfc.vue`) | Full-screen technical proposals rendered from Markdown, with generated section navigation and diagram support. | `components/tela/rfc/rfc.mdx` |
190
191
 
191
192
  **`TelaHome`** — a flex-row shell: a sticky `TelaSidebar` beside a scrolling `TelaHomeContent` column that stacks a page title, a metric-card row (`TelaHomeMetrics`), a filter toolbar (`TelaHomeToolbar`), and a data table. It does **not** own scroll — the sidebar pins itself (`sticky top-0 h-screen`) and the page scrolls as one, no `overflow` container or height hack. Expandable detail rows are an opt-in enhancement.
192
193
 
193
194
  **`TelaDetails`** — a sticky `TelaHeader` + a single scroll container + a primary content column beside a sticky context column, with an optional confirm footer. Two documented variants — a two-column body, and a hero header above the body.
194
195
 
196
+ **`TelaRfc`** — a centered long-form reading column with a built-in fixed `TelaTableOfContents` (H2–H4) at the right. It accepts plain Markdown directly, renders fenced code through `TelaCodeBlock`, renders fenced Mermaid flowcharts, and hides the TOC below 1511px.
197
+
195
198
  This table is the source of truth for layout templates. Rules:
196
199
 
197
200
  - If a layout fits the surface, use it. Don't hand-roll an equivalent.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meistrari/tela-build",
3
- "version": "1.66.0",
3
+ "version": "1.67.1",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "app.config.ts",
@@ -25,6 +25,7 @@
25
25
  "generate-docs": "bun scripts/generate-docs.ts"
26
26
  },
27
27
  "dependencies": {
28
+ "@comark/vue": "0.5.1",
28
29
  "@floating-ui/vue": "1.1.5",
29
30
  "@fontsource-variable/geist-mono": "^5.2.7",
30
31
  "@fontsource-variable/inter": "^5.2.8",
@@ -41,8 +42,10 @@
41
42
  "@vueuse/components": "12.8.2",
42
43
  "@vueuse/core": "12.8.2",
43
44
  "@vueuse/nuxt": "12.8.2",
45
+ "beautiful-mermaid": "1.1.3",
44
46
  "class-variance-authority": "0.7.0",
45
47
  "clsx": "2.1.1",
48
+ "comark": "0.5.1",
46
49
  "glob": "11.0.0",
47
50
  "gsap": "3.12.5",
48
51
  "import-in-the-middle": "1.11.2",
@@ -70,20 +73,21 @@
70
73
  "typescript": "5.8.2",
71
74
  "unified": "11.0.5",
72
75
  "unocss": "66.5.12",
73
- "vue": "3.5.17",
74
76
  "vue-component-meta": "3.0.8",
75
77
  "vue-docgen-api": "4.78.0",
76
78
  "vue-input-otp": "0.3.2",
77
79
  "vue-router": "4.5.0"
78
80
  },
79
81
  "peerDependencies": {
80
- "nuxt": "^3.17.0 || ^4.0.0"
82
+ "nuxt": "^3.17.0 || ^4.0.0",
83
+ "vue": "^3.5.13"
81
84
  },
82
85
  "devDependencies": {
83
86
  "@nuxt/kit": "3.17.7",
84
87
  "@types/node": "18.19.79",
85
88
  "fs-extra": "11.2.0",
86
89
  "gray-matter": "4.0.3",
87
- "nuxt": "3.17.7"
90
+ "nuxt": "3.17.7",
91
+ "vue": "3.5.17"
88
92
  }
89
93
  }