Skip to content

Commit 1beca2c

Browse files
committed
Honor the model's declared page type; default copilot .html to a page
An explicit contentType on create_empty_file always wins (the skill now declares text/x-sim-page for pages, text/html for bespoke raw pages); with no declaration a copilot-created .html defaults to the page type. The first apply_file_edit still re-confirms from the actual content, and the category map knows the internal mime explicitly instead of falling through to the extension.
1 parent 3f91212 commit 1beca2c

8 files changed

Lines changed: 155 additions & 27 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ const TEXT_EDITABLE_MIME_TYPES = new Set([
88
'application/x-yaml',
99
'text/csv',
1010
'text/html',
11+
// Sim pages: internal record type; the file itself is .html.
12+
'text/x-sim-page',
1113
'text/xml',
1214
'application/xml',
1315
'text/css',

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx‎

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
usesSimArtifactStyles,
1414
} from '@/lib/workspace-files/artifact-stylesheet'
1515
import { compileSimPage, isSimPageSource } from '@/lib/workspace-files/page-compile'
16+
import { SIM_PAGE_FONT_URL, simPageFontFace } from '@/lib/workspace-files/page-font'
1617
import { useHorizontalWheelScroll } from '@/app/workspace/[workspaceId]/files/components/file-viewer/use-horizontal-wheel-scroll'
1718
import { type CsvImportFileDescriptor, useCsvTruncationImport } from './csv-import'
1819
import { DataTable } from './data-table'
@@ -144,6 +145,48 @@ const HTML_PREVIEW_BOOTSTRAP = `<script>
144145
})()
145146
</script>`
146147

148+
/**
149+
* Inter for the sandboxed preview: `font-src` allows only `data:`, so the
150+
* host fetches the app-served woff2 once per session and inlines it. Until
151+
* the fetch lands (or off-browser) the page renders on the system stack and
152+
* upgrades on the next build — a font swap, not a layout break.
153+
*/
154+
let interFontFaceCss = ''
155+
let interFontFacePromise: Promise<string> | null = null
156+
function loadInterFontFace(): Promise<string> {
157+
if (!interFontFacePromise) {
158+
interFontFacePromise = fetch(SIM_PAGE_FONT_URL)
159+
.then((response) => (response.ok ? response.arrayBuffer() : null))
160+
.then((buffer) => {
161+
if (!buffer) return ''
162+
const bytes = new Uint8Array(buffer)
163+
let binary = ''
164+
for (let i = 0; i < bytes.length; i += 0x8000) {
165+
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
166+
}
167+
interFontFaceCss = simPageFontFace(`data:font/woff2;base64,${btoa(binary)}`)
168+
return interFontFaceCss
169+
})
170+
.catch(() => '')
171+
}
172+
return interFontFacePromise
173+
}
174+
175+
function useSimPageFontFace(): string {
176+
const [fontFace, setFontFace] = useState(interFontFaceCss)
177+
useEffect(() => {
178+
if (interFontFaceCss) return
179+
let cancelled = false
180+
loadInterFontFace().then((value) => {
181+
if (!cancelled && value) setFontFace(value)
182+
})
183+
return () => {
184+
cancelled = true
185+
}
186+
}, [])
187+
return fontFace
188+
}
189+
147190
/**
148191
* Stamps the app's resolved theme onto the document root. The frame has an
149192
* opaque origin and inherits nothing, so `prefers-color-scheme` inside it
@@ -164,7 +207,8 @@ export function buildHtmlPreviewDocument(
164207
content: string,
165208
theme: 'dark' | 'light' = 'light',
166209
workspaceId?: string,
167-
lenient?: boolean
210+
lenient?: boolean,
211+
fontFaceCss: string = interFontFaceCss
168212
): string {
169213
// The pdf model: a page file STORES its source (frontmatter + markdown +
170214
// sim: fences) and every rendering surface compiles on demand. Partial
@@ -181,7 +225,7 @@ export function buildHtmlPreviewDocument(
181225
// token overrides follow the sheet so the app's live values beat its
182226
// fallbacks, and the shell follows both.
183227
usesSimArtifactStyles(content)
184-
? `<style>${SIM_ARTIFACT_STYLESHEET}</style><style>${simTokenOverrides()}</style>${SIM_ARTIFACT_SHELL}`
228+
? `<style>${fontFaceCss}${SIM_ARTIFACT_STYLESHEET}</style><style>${simTokenOverrides()}</style>${SIM_ARTIFACT_SHELL}`
185229
: '',
186230
HTML_PREVIEW_BOOTSTRAP,
187231
].join('')
@@ -287,6 +331,7 @@ const HtmlPreview = memo(function HtmlPreview({
287331
}) {
288332
const { resolvedTheme } = useTheme()
289333
const router = useRouter()
334+
const fontFaceCss = useSimPageFontFace()
290335
const batchedContent = useStreamBatchedValue(content, isStreaming === true, 2000)
291336
const displayContent = useInlinedWorkspaceImages(batchedContent)
292337
const wrappedContent = buildHtmlPreviewDocument(
@@ -295,7 +340,8 @@ const HtmlPreview = memo(function HtmlPreview({
295340
workspaceId,
296341
// Mid-stream, a fence still being written is malformed by definition —
297342
// suppress the skip notices until the stream settles.
298-
isStreaming === true
343+
isStreaming === true,
344+
fontFaceCss
299345
)
300346

301347
// Receives sim-resource link clicks bridged out of the sandboxed page and

‎apps/sim/lib/copilot/tools/server/files/create-file.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
createWorkspaceFileByPath,
1414
updateWorkspaceFileContentByPath,
1515
} from '@/lib/workspace-files/application/write-workspace-file-by-path'
16+
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile'
1617

1718
const logger = createLogger('CreateFileServerTool')
1819
const CREATE_FILE_TOOL_ID = 'create_empty_file'
@@ -57,7 +58,20 @@ export const createFileServerTool: BaseServerTool<CreateFileArgs, CreateFileResu
5758
}
5859
const outputPath =
5960
outputFile?.path ?? (fileName.startsWith('files/') ? fileName : `files/${fileName}`)
60-
const contentType = outputFile?.mimeType ?? inferContentType(outputPath, explicitType)
61+
// A copilot-created .html file IS a Sim page unless the model says
62+
// otherwise: an explicit contentType (the skill declares
63+
// text/x-sim-page for pages and text/html for a bespoke raw page)
64+
// always wins; with no declaration the .html default is a page. Either
65+
// way the record advertises the file's nature from birth, so the viewer
66+
// renders (never raw code) from the very first streamed byte, and the
67+
// first apply_file_edit re-confirms from the actual content. User
68+
// uploads never pass through here and stay text/html.
69+
const declaredType = outputFile?.mimeType ?? explicitType
70+
const inferredType = inferContentType(outputPath, declaredType)
71+
const contentType =
72+
!declaredType && inferredType === 'text/html' && outputPath.toLowerCase().endsWith('.html')
73+
? SIM_PAGE_CONTENT_TYPE
74+
: inferredType
6175
assertServerToolNotAborted(context)
6276
const mode = outputFile?.mode ?? 'create'
6377
// An empty shell provably contains no secrets; recording that keeps the

‎apps/sim/lib/copilot/tools/server/files/edit-content.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,8 @@ export const editContentServerTool: BaseServerTool<EditContentArgs, EditContentR
281281
// The internal page type: the record advertises what the .html holds so
282282
// surfaces can force the rendered view before content loads. The file
283283
// itself stays .html (serve/download emit text/html).
284+
// create_empty_file stamps copilot .html as a page by default; the
285+
// first real content confirms or corrects that from what was written.
284286
const storedContentType =
285287
isHtmlTarget && isSimPageSource(finalContent) ? SIM_PAGE_CONTENT_TYPE : compiled.sourceMime
286288

‎apps/sim/lib/workspace-files/artifact-stylesheet.test.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,46 @@ describe('docs fidelity', () => {
141141
expect(SIM_ARTIFACT_STYLESHEET).toContain('padding: 5px 0.5rem')
142142
expect(SIM_ARTIFACT_STYLESHEET).toContain('line-height: 20px')
143143
})
144+
145+
// Inter leads the stack; each surface injects the matching @font-face.
146+
it('leads the sans stack with the docs font', () => {
147+
expect(SIM_ARTIFACT_STYLESHEET).toContain('--font-sans: "Inter",')
148+
})
149+
150+
// The docs' prose anchor: 500 weight, 1.5px underline offset 3.5px, hover
151+
// fades to 80% — with chrome links reset back to plain navigation.
152+
it('styles content links as the docs prose anchors', () => {
153+
expect(SIM_ARTIFACT_STYLESHEET).toContain('text-decoration-thickness: 1.5px')
154+
expect(SIM_ARTIFACT_STYLESHEET).toContain('text-underline-offset: 3.5px')
155+
expect(SIM_ARTIFACT_STYLESHEET).toContain('a:hover { opacity: 0.8; }')
156+
expect(SIM_ARTIFACT_STYLESHEET).toContain('.rail a, .page-nav-card { font-weight: 400')
157+
})
158+
159+
// The docs' figure.shiki shell and Code.Viewer metrics.
160+
it('frames fenced code like the docs', () => {
161+
expect(SIM_ARTIFACT_STYLESHEET).toContain('background: var(--code-surface)')
162+
expect(SIM_ARTIFACT_STYLESHEET).toContain('line-height: 21px')
163+
expect(SIM_ARTIFACT_STYLESHEET).toContain(
164+
'.codeblock-copy.is-copied { color: var(--brand-accent); }'
165+
)
166+
expect(SIM_ARTIFACT_SHELL).toContain('M14.25 0.75H2.75') // emcn Duplicate
167+
expect(SIM_ARTIFACT_SHELL).toContain('M18.25 2.75L7.25 15.75') // emcn Check
168+
})
169+
170+
// The docs' inline chip: no border, color unset so linked code keeps the
171+
// link color.
172+
it('keeps the inline code chip borderless and color-neutral', () => {
173+
expect(SIM_ARTIFACT_STYLESHEET).toContain('padding: 0.125rem 0.375rem')
174+
expect(SIM_ARTIFACT_STYLESHEET).not.toContain(
175+
'code {\n font-family: var(--font-mono);\n font-size: 0.84em'
176+
)
177+
})
178+
179+
it('carries the platform selection color', () => {
180+
expect(SIM_ARTIFACT_STYLESHEET).toContain(
181+
'::selection { background-color: var(--selection-bg); }'
182+
)
183+
})
144184
})
145185

146186
describe('simTokenOverrides', () => {

‎apps/sim/lib/workspace-files/artifact-stylesheet.ts‎

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,9 @@
1010
* A page opting in renders as a page of the Sim docs, not a third design
1111
* system.
1212
*
13-
* Injected only into the preview document, never written into the stored
14-
* file, so restyling every existing page is a change to this constant. The
15-
* preview is also what the share page renders through, so shared links pick
16-
* it up too; a raw download is the one path that sees unstyled markup.
13+
* Never written into the stored file: the preview injects it live and
14+
* page-document.ts bakes it into standalone/share/download documents, so
15+
* restyling every existing page is a change to this constant.
1716
*/
1817

1918
/** Marker a page carries to request {@link SIM_ARTIFACT_STYLESHEET}. */
@@ -100,9 +99,11 @@ export function simTokenOverrides(): string {
10099
* 2px color bar down the start edge) with the docs' shadow removal.
101100
* - layout: global.css --spacing-fd-container 1400px, --fd-sidebar-width
102101
* 300px, TOC column 268px, --content-gap 2.25rem.
103-
* The docs' webfont (Inter under --font-geist-sans) cannot cross the sandbox
104-
* (font CDNs and app-origin fetches are blocked), so the system stack stands
105-
* in for it.
102+
* The docs' webfont (Inter under --font-geist-sans) leads --font-sans; each
103+
* surface injects the matching @font-face (see lib/workspace-files/page-font
104+
* — the app-served URL for standalone documents, a host-fetched data: URI
105+
* for the sandboxed preview), falling back to the system stack until it
106+
* loads.
106107
*/
107108
export const SIM_ARTIFACT_STYLESHEET = `
108109
:root {
@@ -692,35 +693,39 @@ export const SIM_ARTIFACT_SHELL = `<script>
692693
paintThemeButton()
693694
bar.append(title, search, themeButton)
694695
695-
// Code blocks get the docs frame: a header with the language label and a
696-
// copy button above each fenced block.
696+
// Code blocks get the docs frame: like the docs' prose fences, an
697+
// untitled block has no header row — just the floating copy control,
698+
// emcn's Duplicate icon flipping to a brand-accent Check while copied.
699+
const COPY_ICON = '<svg ' + ICON_ATTRS + '><path d="M14.25 0.75H2.75C1.64543 0.75 0.75 1.64543 0.75 2.75V14.25"/><rect x="5.25" y="5.25" width="14" height="14" rx="2"/></svg>'
700+
const COPIED_ICON = '<svg ' + ICON_ATTRS + '><path d="M18.25 2.75L7.25 15.75L1.75 10.25"/></svg>'
697701
for (const pre of [...main.querySelectorAll('pre')]) {
698702
if (pre.closest('.codeblock')) continue
699703
const codeEl = pre.querySelector('code')
700-
const langMatch = codeEl && codeEl.className.match(/language-([\\w-]+)/)
701704
const frame = document.createElement('figure')
702705
frame.className = 'codeblock'
703-
const head = document.createElement('div')
704-
head.className = 'codeblock-head'
705-
const lang = document.createElement('span')
706-
lang.textContent = langMatch ? langMatch[1] : 'code'
707706
const copy = document.createElement('button')
708707
copy.className = 'codeblock-copy'
709708
copy.type = 'button'
710-
copy.textContent = 'Copy'
709+
copy.setAttribute('aria-label', 'Copy Text')
710+
copy.innerHTML = COPY_ICON
711+
let copyResetTimer
711712
copy.addEventListener('click', async () => {
712713
const text = (codeEl || pre).textContent || ''
713714
try {
714715
await navigator.clipboard.writeText(text)
715-
copy.textContent = 'Copied'
716-
} catch {
717-
copy.textContent = 'Select + copy'
718-
}
719-
setTimeout(() => { copy.textContent = 'Copy' }, 1500)
716+
copy.innerHTML = COPIED_ICON
717+
copy.classList.add('is-copied')
718+
copy.setAttribute('aria-label', 'Copied Text')
719+
clearTimeout(copyResetTimer)
720+
copyResetTimer = setTimeout(() => {
721+
copy.innerHTML = COPY_ICON
722+
copy.classList.remove('is-copied')
723+
copy.setAttribute('aria-label', 'Copy Text')
724+
}, 1500)
725+
} catch {}
720726
})
721-
head.append(lang, copy)
722727
pre.replaceWith(frame)
723-
frame.append(head, pre)
728+
frame.append(copy, pre)
724729
}
725730
726731
const cols = document.createElement('div')

‎apps/sim/lib/workspace-files/page-document.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
SIM_ARTIFACT_STYLESHEET,
44
} from '@/lib/workspace-files/artifact-stylesheet'
55
import { compileSimPage } from '@/lib/workspace-files/page-compile'
6+
import { SIM_PAGE_FONT_URL, simPageFontFace } from '@/lib/workspace-files/page-font'
67

78
/**
89
* Renders page source as a fully self-contained styled document for the
@@ -16,6 +17,6 @@ export function renderSimPageDocument(source: string, options?: { workspaceId?:
1617
const compiled = compileSimPage(source, options)
1718
return compiled.replace(
1819
'</head>',
19-
`<style>${SIM_ARTIFACT_STYLESHEET}</style>${SIM_ARTIFACT_SHELL}</head>`
20+
`<style>${simPageFontFace(SIM_PAGE_FONT_URL)}${SIM_ARTIFACT_STYLESHEET}</style>${SIM_ARTIFACT_SHELL}</head>`
2021
)
2122
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* The docs render Inter (their `--font-geist-sans` resolves to Inter via
3+
* next/font), so generated pages carry the same face. The asset in
4+
* `public/fonts/` is next/font's own latin variable subset (weights 100–900
5+
* in one file — the docs use intermediate weights like 430/470/550), copied
6+
* from the app build's font cache, so the page and the platform serve
7+
* byte-identical Inter.
8+
*
9+
* Two delivery paths, because the preview sandbox allows `font-src data:`
10+
* only: standalone/share/download documents reference the app-served URL,
11+
* while the preview host fetches the file once and inlines it as a data: URI.
12+
*/
13+
export const SIM_PAGE_FONT_URL = '/fonts/inter-variable-latin.woff2'
14+
15+
/** One `@font-face` covering Inter's full variable weight range. */
16+
export function simPageFontFace(src: string): string {
17+
return `@font-face{font-family:"Inter";font-style:normal;font-weight:100 900;font-display:swap;src:url(${src}) format("woff2")}`
18+
}

0 commit comments

Comments
 (0)