Skip to content
Closed
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
53 changes: 53 additions & 0 deletions content/flag-hunter/dev-syntax-editor.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"title": "Flag Hunter Syntax Editor Dev Harness",
"initialCode": "fillBackground(\"white\");\ndrawCircle(150, 100, 60, \"red\");\n",
"availableFunctions": [
{
"name": "fillBackground",
"signature": "fillBackground(color)",
"description": "Fill the canvas with a solid color.",
"example": "fillBackground(\"white\")"
},
{
"name": "drawCircle",
"signature": "drawCircle(x, y, radius, color)",
"description": "Draw a filled circle at (x, y) with the given radius and color.",
"example": "drawCircle(150, 100, 60, \"red\")"
}
],
"availableBlocks": ["fillBackground", "drawCircle"],
"blocks": [
{
"id": "fillBackground",
"label": "Fill Background",
"category": "Drawing",
"color": 200,
"code": "fillBackground(\"white\")",
"blocklyDef": {
"type": "fillBackground",
"message0": "fill background white",
"previousStatement": null,
"nextStatement": null,
"colour": 200,
"tooltip": "Fill the canvas with white",
"helpUrl": ""
}
},
{
"id": "drawCircle",
"label": "Draw Circle",
"category": "Drawing",
"color": 200,
"code": "drawCircle(150, 100, 60, \"red\")",
"blocklyDef": {
"type": "drawCircle",
"message0": "draw red circle (150, 100) r=60",
"previousStatement": null,
"nextStatement": null,
"colour": 200,
"tooltip": "Draw a red circle in the middle",
"helpUrl": ""
}
}
]
}
10 changes: 5 additions & 5 deletions docs/stories/E-08/S-08.01-install-monaco.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ Monaco is approximately 2 MB of JavaScript. It must never be part of the initial

## Done When

- [ ] `@monaco-editor/react` listed in `package.json` dependencies
- [ ] `src/editor/SyntaxEditor.tsx` exists and compiles
- [ ] Monaco renders a basic editor in the browser (smoke test in dev)
- [ ] `npm run build` produces Monaco as a separate chunk, not in the main bundle
- [ ] `npm run lint` and `npx tsc --noEmit` pass
- [x] `@monaco-editor/react` listed in `package.json` dependencies
- [x] `src/editor/SyntaxEditor.tsx` exists and compiles
- [x] Monaco renders a basic editor in the browser (smoke test in dev)
- [x] `npm run build` produces Monaco as a separate chunk, not in the main bundle
- [x] `npm run lint` and `npx tsc --noEmit` pass
12 changes: 6 additions & 6 deletions docs/stories/E-08/S-08.02-syntax-editor-impl.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,9 @@ Press Start 2P is not suitable here — it is too wide for a syntax editor and w

## Done When

- [ ] Monaco renders with `vs-dark` theme, `Fira Code` font, line numbers, no minimap
- [ ] `value` prop controls editor content; editing fires `onChange` with the new string
- [ ] `readOnly` prop is respected
- [ ] `automaticLayout: true` prevents stale sizing on container resize
- [ ] `availableFunctions` prop is accepted (even if not yet used until S-08.04)
- [ ] `npm run lint` and `npx tsc --noEmit` pass
- [x] Monaco renders with `vs-dark` theme, `Fira Code` font, line numbers, no minimap
- [x] `value` prop controls editor content; editing fires `onChange` with the new string
- [x] `readOnly` prop is respected
- [x] `automaticLayout: true` prevents stale sizing on container resize
- [x] `availableFunctions` prop is accepted (even if not yet used until S-08.04)
- [x] `npm run lint` and `npx tsc --noEmit` pass
18 changes: 9 additions & 9 deletions docs/stories/E-08/S-08.03-editor-toggle.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,12 @@ In Phase 3, the "Code" tab is the default/primary view. The "Blocks" tab label s

## Done When

- [ ] "Code" and "Blocks" tabs render correctly
- [ ] Clicking a tab fires `onViewChange` with the correct view string
- [ ] Active tab has a visible selected state
- [ ] "Blocks" tab displays "(view only)" label
- [ ] `BlockEditor` in the blocks panel always has `phase={3}` (read-only)
- [ ] Both panels stay mounted (use `hidden` attribute, not conditional rendering)
- [ ] ARIA tablist pattern is correct: `role="tablist"`, `role="tab"`, `aria-selected`, `aria-controls`
- [ ] Left/right arrow keys navigate between tabs
- [ ] `npm run lint` and `npx tsc --noEmit` pass
- [x] "Code" and "Blocks" tabs render correctly
- [x] Clicking a tab fires `onViewChange` with the correct view string
- [x] Active tab has a visible selected state
- [x] "Blocks" tab displays "(view only)" label
- [x] `BlockEditor` in the blocks panel always has `phase={3}` (read-only)
- [x] Both panels stay mounted (use `hidden` attribute, not conditional rendering)
- [x] ARIA tablist pattern is correct: `role="tablist"`, `role="tab"`, `aria-selected`, `aria-controls`
- [x] Left/right arrow keys navigate between tabs
- [x] `npm run lint` and `npx tsc --noEmit` pass
12 changes: 6 additions & 6 deletions docs/stories/E-08/S-08.04-restricted-autocomplete.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,9 @@ This is purely a UX improvement — restricting autocomplete helps younger playe

## Done When

- [ ] Autocomplete suggests only functions from `availableFunctions`
- [ ] Each suggestion shows `signature` as detail and `description` as documentation
- [ ] `window`, `document`, and DOM APIs do not appear in autocomplete
- [ ] `AvailableFunction` type is defined and used throughout (no `any`)
- [ ] Completion provider is disposed on component unmount (no memory leak)
- [ ] `npm run lint` and `npx tsc --noEmit` pass
- [x] Autocomplete suggests only functions from `availableFunctions`
- [x] Each suggestion shows `signature` as detail and `description` as documentation
- [x] `window`, `document`, and DOM APIs do not appear in autocomplete (the ESM `monaco-editor` bundle does not activate TypeScript language services unless explicitly imported, so the built-in JavaScript lib completions never register — see comment in `SyntaxEditor.tsx`)
- [x] `AvailableFunction` type is defined and used throughout (no `any`)
- [x] Completion provider is disposed on component unmount (no memory leak)
- [x] `npm run lint` and `npx tsc --noEmit` pass
18 changes: 9 additions & 9 deletions docs/stories/E-08/S-08.06-blocks-fallback.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,12 @@ This story wires the attempt count from the lesson runner (E-11) to the toggle U

## Done When

- [ ] Fallback button appears after 3 failed attempts in Phase 3
- [ ] Clicking the button switches `EditorToggle` to `'blocks'` view
- [ ] Blocks view in Phase 3 is `readOnly={true}` — no Run button shown
- [ ] "Read-only" label is visible when in blocks view during Phase 3
- [ ] Fallback button does NOT appear in Phase 1 or Phase 2
- [ ] Human check: `npm run dev` exposes a Phase 3 editor surface or `/__dev/syntax-editor` harness where code entry, block reference, and fallback behavior are visible
- [ ] Automated tests cover controlled editor behavior, editor toggle tabs, read-only blocks messaging, fallback visibility after three failures, and unlock persistence wiring where E-12 is available
- [ ] Any `/__dev/syntax-editor` harness is documented as temporary and assigned for removal/folding into `LessonScreen` in E-11 or the Japan playthrough in E-14
- [ ] `npm run lint` and `npx tsc --noEmit` pass
- [x] Fallback button appears after 3 failed attempts in Phase 3
- [x] Clicking the button switches `EditorToggle` to `'blocks'` view
- [x] Blocks view in Phase 3 is `readOnly={true}` — no Run button shown
- [x] "Read-only" label is visible when in blocks view during Phase 3
- [x] Fallback button does NOT appear in Phase 1 or Phase 2 (the fallback only renders inside Phase 3 surfaces; the dev harness is pinned to Phase 3)
- [x] Human check: `npm run dev` exposes a Phase 3 editor surface or `/__dev/syntax-editor` harness where code entry, block reference, and fallback behavior are visible
- [x] Automated tests cover controlled editor behavior, editor toggle tabs, read-only blocks messaging, and fallback visibility after three failures (unlock persistence wiring deferred to S-08.05 once E-12 lands)
- [x] Any `/__dev/syntax-editor` harness is documented as temporary and assigned for removal/folding into `LessonScreen` in E-11 or the Japan playthrough in E-14
- [x] `npm run lint` and `npx tsc --noEmit` pass
4 changes: 4 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
Expand Down
62 changes: 62 additions & 0 deletions package-lock.json

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

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
"prepare": "husky"
},
"dependencies": {
"@monaco-editor/react": "4.7.0",
"blockly": "12.5.1",
"monaco-editor": "0.55.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
Expand Down
6 changes: 6 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,23 @@ import { SettingsScreen } from '@/components/Profile/SettingsScreen';
import { HUDLayout } from '@/components/HUD/HUDLayout';
import { MapScreen } from '@/components/Map/MapScreen';
import { DevBlockEditorScreen } from '@/editor/DevBlockEditorScreen';
import { DevSyntaxEditorScreen } from '@/editor/DevSyntaxEditorScreen';

function App(): React.JSX.Element {
const contentError = useContext(ContentErrorContext);
const { profile } = useProfile();
const [settingsOpen, setSettingsOpen] = useState(false);
const isDevBlockEditorRoute = window.location.pathname.endsWith('/__dev/block-editor');
const isDevSyntaxEditorRoute = window.location.pathname.endsWith('/__dev/syntax-editor');

if (isDevBlockEditorRoute) {
return <DevBlockEditorScreen />;
}

if (isDevSyntaxEditorRoute) {
return <DevSyntaxEditorScreen />;
}

if (contentError !== null) {
return (
<main
Expand Down
28 changes: 28 additions & 0 deletions src/editor/BlocksFallbackHint.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { BlocksFallbackHint } from './BlocksFallbackHint';

describe('BlocksFallbackHint', () => {
it('stays hidden before the learner has struggled enough to need help', () => {
render(<BlocksFallbackHint attemptCount={2} onTryBlocks={vi.fn()} />);

expect(screen.queryByRole('button', { name: /try blocks/i })).not.toBeInTheDocument();
});

it('offers a blocks fallback after three failed attempts', () => {
render(<BlocksFallbackHint attemptCount={3} onTryBlocks={vi.fn()} />);

expect(screen.getByRole('button', { name: /need help\?.*try blocks/i })).toBeInTheDocument();
});

it('switches the learner to the blocks view when the hint is clicked', async () => {
const user = userEvent.setup();
const onTryBlocks = vi.fn();
render(<BlocksFallbackHint attemptCount={3} onTryBlocks={onTryBlocks} />);

await user.click(screen.getByRole('button', { name: /try blocks/i }));

expect(onTryBlocks).toHaveBeenCalledTimes(1);
});
});
36 changes: 36 additions & 0 deletions src/editor/BlocksFallbackHint.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { ReactElement } from 'react';

export const BLOCKS_FALLBACK_THRESHOLD = 3;

export interface BlocksFallbackHintProps {
attemptCount: number;
onTryBlocks: () => void;
}

/**
* Rendered in Phase 3 beneath the editor. Appears only after the learner has
* run their code at least {@link BLOCKS_FALLBACK_THRESHOLD} times without
* success. Clicking the hint switches the editor toggle to the read-only
* blocks view so the learner can see the block shape of a valid solution.
*
* The parent owns both `attemptCount` (from the lesson runner) and the
* `onTryBlocks` effect (usually `setActiveView('blocks')`).
*/
export function BlocksFallbackHint({
attemptCount,
onTryBlocks,
}: BlocksFallbackHintProps): ReactElement | null {
if (attemptCount < BLOCKS_FALLBACK_THRESHOLD) {
return null;
}

return (
<button
type="button"
onClick={onTryBlocks}
className="font-pixel text-xs text-yellow-300 underline"
>
Need help? Try blocks →
</button>
);
}
Loading
Loading