Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Playwright visual tests live in `tests/e2e/visual.spec.ts`.

The suite is intentionally split by responsibility:

- browser tests protect exported-app behavior, fragment-driven rendering, downloads, clipboard copy, print flow, themes, and layout hierarchy
- browser tests protect exported-app behavior, fragment-driven rendering, downloads, clipboard copy, print flow, themes, and layout hierarchy (including mobile toolbar and default code-wrap checks in `tests/e2e/viewer.spec.ts`)
- visual tests protect empty state, artifact views, theme presentation, and compact-content spacing
- component tests protect selector/disclosure UI contracts
- unit tests protect transport codecs, envelope validation, diff parsing, and language inference
Expand Down
1 change: 0 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

74 changes: 72 additions & 2 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -1883,19 +1883,78 @@ select {

.viewer-toolbar {
width: 100%;
max-width: 100%;
min-width: 0;
justify-content: flex-start;
gap: 0.38rem;
flex-wrap: wrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
}

/* Compact touch sizing for all action surfaces at ≤640px */
.artifact-action {
flex: 1 1 calc(50% - 0.25rem);
min-height: 2.55rem;
padding: 0.46rem 0.65rem;
font-size: 0.71rem;
}

/* Viewer shell: avoid flex-shrink clipping on standalone buttons; pair row only inside raw/rendered toggle */
.viewer-toolbar .artifact-action {
flex: 0 0 auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
max-width: 100%;
}

.viewer-toolbar .diff-view-toggle {
display: flex;
flex: 1 1 100%;
flex-wrap: wrap;
gap: 0.38rem;
min-width: 0;
}

.viewer-toolbar .diff-view-toggle .artifact-action {
flex: 1 1 calc(50% - 0.25rem);
min-width: 0;
max-width: 100%;
}

.viewer-toolbar > .artifact-action.is-primary {
flex-basis: 100%;
flex: 1 1 100%;
max-width: 100%;
}

/* JSON tree/raw toggle: same pair-row behavior as markdown raw/rendered (not covered by viewer-toolbar rules) */
.json-renderer-toolbar .diff-view-toggle {
display: flex;
flex-wrap: wrap;
gap: 0.38rem;
min-width: 0;
}

.json-renderer-toolbar .diff-view-toggle .artifact-action {
flex: 1 1 calc(50% - 0.25rem);
min-width: 0;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.code-renderer-toolbar,
.diff-renderer-toolbar,
.csv-renderer-toolbar,
.json-renderer-toolbar {
max-width: 100%;
min-width: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
}

.viewer-frame-primary {
Expand Down Expand Up @@ -1960,6 +2019,17 @@ select {
.artifact-action {
flex-basis: 100%;
}

/* Same selector specificity as the 640px pair rule; this block wins at ≤360px because it appears later in the stylesheet */
.viewer-toolbar .diff-view-toggle .artifact-action {
flex: 1 1 100%;
max-width: 100%;
}

.json-renderer-toolbar .diff-view-toggle .artifact-action {
flex: 1 1 100%;
max-width: 100%;
}
}

@media (min-width: 768px) {
Expand Down
58 changes: 56 additions & 2 deletions src/components/renderers/code-renderer.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { useTheme } from "next-themes";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { WrapText } from "lucide-react";
import { EditorState, RangeSetBuilder } from "@codemirror/state";
import { indentationMarkers } from "@replit/codemirror-indentation-markers";
Expand All @@ -22,6 +22,10 @@ import { bracketMatching, defaultHighlightStyle, syntaxTree, syntaxHighlighting
import { detectCodeLanguage, loadLanguageSupport } from "@/lib/code/language";
import type { CodeArtifact } from "@/lib/payload/schema";

const MOBILE_CODE_MEDIA_QUERY = "(max-width: 640px)";

type WrapPreference = "auto" | "on" | "off";

type CodeRendererProps = {
artifact: CodeArtifact;
compact?: boolean;
Expand Down Expand Up @@ -163,6 +167,7 @@ const rainbowBrackets = ViewPlugin.fromClass(
*/
export function CodeRenderer({ artifact, compact = false, onReady }: CodeRendererProps) {
const hostRef = useRef<HTMLDivElement | null>(null);
const wrapPreferenceRef = useRef<WrapPreference>("auto");
const [wrapLines, setWrapLines] = useState(compact);
const [languageExtension, setLanguageExtension] = useState<Awaited<ReturnType<typeof loadLanguageSupport>>>(null);
const [isReady, setIsReady] = useState(false);
Expand All @@ -184,6 +189,47 @@ export function CodeRenderer({ artifact, compact = false, onReady }: CodeRendere
const editorTheme = useMemo(() => createEditorTheme(isCmDark), [isCmDark]);
const language = useMemo(() => detectCodeLanguage(artifact.filename, artifact.language), [artifact.filename, artifact.language]);

// Runs before paint so the first CodeMirror mount matches the viewport (call sites use dynamic(..., { ssr: false })).
// Preference stays on wrapPreferenceRef (not state) so the matchMedia listener closure stays correct without
// re-subscribing each render. compact=true resets to "auto"; compact is static at all call sites today.
useLayoutEffect(() => {
if (compact) {
setWrapLines(true);
wrapPreferenceRef.current = "auto";
return;
}

if (typeof window === "undefined" || !window.matchMedia) {
return;
}

const mediaQuery = window.matchMedia(MOBILE_CODE_MEDIA_QUERY);

const applyWrapFromPreference = () => {
const preference = wrapPreferenceRef.current;
if (preference === "on") {
setWrapLines(true);
return;
}
if (preference === "off") {
setWrapLines(false);
return;
}
setWrapLines(mediaQuery.matches);
};

applyWrapFromPreference();

const handleChange = () => {
applyWrapFromPreference();
};

mediaQuery.addEventListener("change", handleChange);
return () => {
mediaQuery.removeEventListener("change", handleChange);
};
}, [compact]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Missing media query listener - The useEffect only checks matchMedia once at mount but doesn't listen for viewport changes. If the user resizes the window from wide to narrow after mount, wrapLines won't update to reflect the new viewport width.


useEffect(() => {
let cancelled = false;

Expand Down Expand Up @@ -283,7 +329,15 @@ export function CodeRenderer({ artifact, compact = false, onReady }: CodeRendere
<span className="mono-pill code-renderer-language-pill">{language}</span>
<span className="section-kicker code-renderer-readonly-label">read-only codemirror</span>
</div>
<button type="button" className="artifact-action is-code" onClick={() => setWrapLines((value) => !value)}>
<button
type="button"
className="artifact-action is-code"
onClick={() => {
const next = !wrapLines;
wrapPreferenceRef.current = next ? "on" : "off";
setWrapLines(next);
}}
>
<WrapText className="h-3.5 w-3.5" />
{wrapLines ? "Disable wrap" : "Enable wrap"}
</button>
Expand Down
Loading
Loading