diff --git a/CLAUDE.md b/CLAUDE.md index 4525cac..23d100e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this is -The glpython IDE: a browser Python IDE for the classroom, deployed to GitHub Pages at the organization's root, https://glpython-edu.github.io/. The repo is `glpython-edu/glpython-edu.github.io` (that name is what makes it the root site); its local clone is `../glpython`. It is only the **shell**: header, file sidebar, language menu, Microsoft sign-in (MSAL redirect flow) and project persistence (local storage, folder/ZIP import, ZIP export, share links). Everything inside the workspace is the `` component from **glpython-editor** (`../glpython-editor`): the Monaco editor, the Pyodide worker, the REPL, the visualizer, the turtle panel. Changes to those belong there, not here. +The glpython IDE: a browser Python IDE for the classroom, deployed to GitHub Pages at the organization's root, https://glpython-edu.github.io/. The repo is `glpython-edu/glpython-edu.github.io` (that name is what makes it the root site); its local clone is `../glpython`. It is only the **shell**: the top bar (with the workspace's buttons portaled in), the files drawer and the ⋮ menu (README «Layout»), Microsoft sign-in (MSAL redirect flow) and project persistence (local storage, folder/ZIP import, ZIP export, share links). Everything inside the workspace is the `` component from **glpython-editor** (`../glpython-editor`): the Monaco editor, the Pyodide worker, the REPL, the visualizer, the turtle panel. Changes to those belong there, not here. Sibling repos: `../glpython-editor` (the component, runtime and LiaScript embed; read its CLAUDE.md) and `../glpython-liascript` (the LiaScript macro). @@ -20,7 +20,8 @@ There are no tests. `.github/workflows/ci.yml` runs `npm ci`, the build (tsc cat ## Layout -- `src/App.tsx` — the shell. It renders `GlPythonWorkspace` and uses glpython-editor's project helpers (`addProjectFile`, `renameProjectFile`, …). +- `src/App.tsx` — the shell. It renders `GlPythonWorkspace` (stacked layout; its buttons go into the bar through `actionsContainer`, every file is a tab with `closeFileTabs: false`) and uses glpython-editor's project helpers (`addProjectFile`, `renameProjectFile`, …). +- `src/index.css` — the shell's styles (`ide-*` classes), from glpython-editor's `--glp-*` tokens. glpython-editor's `style.css` still has the old shell's rules (`.app-shell`, `.topbar`, `.sidebar`, …), now unused. - `src/i18n.ts` — only the shell's own strings (`ShellStrings`, de/en). The workspace's strings come from glpython-editor's `coreStrings`, so don't copy them here. A new workspace string goes into glpython-editor's `src/i18n.ts`. - `src/projectStorage.ts` (local storage, folder/ZIP) and `src/projectShare.ts` (`?project=` share links, lz-string, versioned payload). - `src/auth.ts` — MSAL. Only active with `VITE_ENTRA_CLIENT_ID` in `.env.local` (README «Microsoft sign-in»). diff --git a/README.md b/README.md index ea1e145..f82611e 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ A browser-based educational Python IDE with Microsoft 365 and OneDrive integration. -This repository is the **shell application**: the classroom chrome (header, file -sidebar, lesson header, auth, project persistence) that embeds the reusable +This repository is the **shell application**: the classroom chrome (top bar, +files drawer, menu, auth, project persistence) that embeds the reusable editor/visualizer/graphics core from [glpython-editor](https://github.com/glpython-edu/glpython-editor) via the `` component. The split exists so the editor, Python @@ -13,9 +13,29 @@ in other contexts beyond this classroom shell. - **glpython-editor** owns: the Monaco-based code editor, the Pyodide Web Worker and its `input()`/`gturtle` runtime, the step-by-step execution visualizer, and the turtle graphics canvas. -- **glpython** (this repo) owns: the overall page layout, the file - sidebar and lesson metadata, language switching, Microsoft sign-in, and - project persistence (local storage, ZIP/folder import-export, URL sharing). +- **glpython** (this repo) owns: the overall page layout, the files drawer, + language switching, Microsoft sign-in, and project persistence (local + storage, ZIP/folder import-export, URL sharing). + +## Layout + +The IDE looks like the LiaScript embed, only larger: + +- **One bar on top:** the logo, the workspace's own buttons in the embed's + order and labels (**▶ Ausführen · Tests · Visualisieren**; ■ Stoppen in + place of ▶ while Python is busy), and on the right **Teilen** and **⋮** + (Python neu starten, open a folder or a ZIP project, download, language, and + under development tools: delete the local project, with a confirmation). + There is no Zurücksetzen in the bar: ▶ already starts with a fresh namespace + and an empty console, ■ stops a hung program, and «Python neu starten» (a new + workspace, as the embed's Reset; the code stays) is in ⋮. +- **Left edge:** **Dateien** (a drawer over the editor: the project name, its + files, new, rename, delete) and **Python-Referenz**, labelled in their + tooltips. Every file is also a tab above the editor. +- **Below:** the workspace in its stacked layout (editor on top, console + below, a draggable divider; graphics beside the editor only while a program + draws; the visualizer in place of the editor). See glpython-editor's README. +- The runtime's status shows only while it loads or failed; no footer. ## Run locally @@ -42,22 +62,23 @@ committing, and run `npm install` again to restore the published dependency. ## Language -The interface language can be changed from the language menu in the header, -which shows the currently active language and lists all available options in -a dropdown. On first load, the language is auto-detected from the browser's +The interface language can be changed in the ⋮ menu, which marks the active +language. On first load, the language is auto-detected from the browser's language settings and then remembered per browser via local storage. ## Project persistence The current project is saved automatically in browser local storage. The -header supports: - -- **Open folder** in browsers with the File System Access API -- **Open ZIP** in all modern browsers with file upload support -- **Download ZIP** with all project files and `glpython.json` metadata -- **Share project**, which serializes the entire project structure (files, - contents, and metadata) into a compressed URL fragment so a project can be - shared or reopened without a server, similar to +⋮ menu and the bar support: + +- **Ordner öffnen** in browsers with the File System Access API +- **Projekt öffnen** (a ZIP) in all modern browsers with file upload support +- **Projekt herunterladen** as a ZIP with all project files and `glpython.json` + metadata +- **Teilen**, which serializes the entire project structure (files, contents, + and metadata) into a compressed link (`?project=`, or `#project=` beyond + GitHub Pages' URL limit) so a project can be shared or reopened without a + server, similar to [webtigerpython.ethz.ch](https://webtigerpython.ethz.ch/) ZIP projects preserve relative paths, the project name, and the configured diff --git a/package-lock.json b/package-lock.json index daca9b7..3273ece 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "dependencies": { "@azure/msal-browser": "^5.22.0", - "glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#v0.1.9", + "glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#v0.1.11", "jszip": "^3.10.2", "lucide-react": "^1.47.0", "lz-string": "^1.5.0", @@ -803,7 +803,7 @@ }, "node_modules/glpython-editor": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/glpython-edu/glpython-editor.git#5af8711fef409e9e476ad645867c88128dbb504d", + "resolved": "git+ssh://git@github.com/glpython-edu/glpython-editor.git#7f593b183ae3a12f0ef0157976b5db33f00f7b9c", "peerDependencies": { "@monaco-editor/react": "^4.7.0", "lucide-react": "^1.47.0", diff --git a/package.json b/package.json index d850c2b..1358a94 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@azure/msal-browser": "^5.22.0", - "glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#v0.1.9", + "glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#v0.1.11", "jszip": "^3.10.2", "lucide-react": "^1.47.0", "lz-string": "^1.5.0", diff --git a/src/App.tsx b/src/App.tsx index e7804e6..dcdac3e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,9 @@ -import { useEffect, useRef, useState } from 'react' -import { BookOpen, ChevronDown, Download, FileCode2, FolderOpen, Languages, Link2, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Pencil, Plus, Sparkles, SquareTerminal, Trash2, Upload } from 'lucide-react' +import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react' +import { BookOpen, CircleHelp, Download, EllipsisVertical, FileCode2, Folder, FolderOpen, Link2, Pencil, Plus, RotateCcw, Sparkles, Trash2, Upload, X } from 'lucide-react' import { GlPythonWorkspace, addProjectFile, deleteProjectFile, initialProject, renameProjectFile, updateProjectFile } from 'glpython-editor' import type { ProjectFileKind } from 'glpython-editor' import 'glpython-editor/style.css' import { initializeAuth } from './auth' -import type { AccountInfo } from '@azure/msal-browser' import { clearLocalProject, exportProjectZip, importProjectFolder, importProjectZip, loadLocalProject, saveLocalProject } from './projectStorage' import { buildShareUrl, consumeSharedProjectFromLocation } from './projectShare' import { detectLocale, localeNames, saveLocale, translations, type Locale } from './i18n' @@ -24,47 +23,87 @@ const startupProject = (() => { return loadLocalProject(initialProject) })() +// Moves keyboard focus through a menu's items with the arrow keys, Home and +// End, as a menu does; Escape is the caller's. +function moveMenuFocus(event: ReactKeyboardEvent, menu: HTMLElement | null) { + const items = [...(menu?.querySelectorAll('[role^="menuitem"]') ?? [])] + if (items.length === 0) return + const index = items.indexOf(document.activeElement as HTMLElement) + const next = ({ ArrowDown: index + 1, ArrowUp: index - 1, Home: 0, End: items.length - 1 } as Record)[event.key] + if (next === undefined) return + event.preventDefault() + items[(next + items.length) % items.length].focus() +} + +// The IDE is the embed, only larger (P18): one bar on top with the +// workspace's own buttons (▶ Ausführen · Tests · Visualisieren, put there by +// the workspace), Teilen and a ⋮ menu for what is rarely needed (among it +// «Python neu starten»: ▶ already starts afresh and ■ stops a hung program, +// and learners read «Zurücksetzen» as "reset my code"); +// at the left edge the files (a drawer) and the Python reference; below, the +// workspace in its stacked layout. function App() { const [locale, setLocale] = useState(() => detectLocale()) const t = translations[locale] const [project, setProject] = useState(() => startupProject) const [activeFile, setActiveFile] = useState(() => startupProject.mainFile) - const [openFiles, setOpenFiles] = useState(() => [startupProject.mainFile]) - const [, setAccount] = useState(null) const [authErrorKey, setAuthErrorKey] = useState<'' | 'init' | 'config' | 'cancelled'>('') - const [isSidebarOpen, setIsSidebarOpen] = useState(true) - // Closed by default; auto-opened by the workspace itself once a project - // importing gturtle is actually run, instead of always showing an empty - // panel for projects that never draw anything. + // The workspace decides itself when to show graphics (only while the + // program draws); these props are for hosts with their own toggle. const [isGraphicsOpen, setIsGraphicsOpen] = useState(false) + const [actionsContainer, setActionsContainer] = useState(null) + const [isDrawerOpen, setIsDrawerOpen] = useState(false) + const [isMenuOpen, setIsMenuOpen] = useState(false) + const [shareCopied, setShareCopied] = useState(false) + // «Python neu starten» mounts the workspace anew: a new Python, an empty + // console - as the embed's Reset. The code is the IDE's and stays. + const [workspaceGeneration, setWorkspaceGeneration] = useState(0) const zipInputRef = useRef(null) - const languageMenuRef = useRef(null) + const drawerRef = useRef(null) + const filesButtonRef = useRef(null) + const menuRef = useRef(null) + const menuButtonRef = useRef(null) useEffect(() => { saveLocalProject(project) }, [project]) useEffect(() => { - initializeAuth().then(setAccount).catch(() => setAuthErrorKey('init')) + initializeAuth().catch(() => setAuthErrorKey('init')) }, []) - const selectLocale = (nextLocale: Locale) => { - setLocale(nextLocale) - saveLocale(nextLocale) - if (languageMenuRef.current) languageMenuRef.current.open = false - } + // The drawer and the menu take keyboard focus when they open; a click + // elsewhere closes the menu. + useEffect(() => { + if (isDrawerOpen) drawerRef.current?.querySelector('button')?.focus() + }, [isDrawerOpen]) + useEffect(() => { + if (!isMenuOpen) return + menuRef.current?.querySelector('[role^="menuitem"]')?.focus() + const closeOnOutsideClick = (event: PointerEvent) => { + if (!menuRef.current?.contains(event.target as Node) && !menuButtonRef.current?.contains(event.target as Node)) setIsMenuOpen(false) + } + document.addEventListener('pointerdown', closeOnOutsideClick) + return () => document.removeEventListener('pointerdown', closeOnOutsideClick) + }, [isMenuOpen]) - const openFile = (path: string) => { - setActiveFile(path) - setOpenFiles((currentFiles) => currentFiles.includes(path) ? currentFiles : [...currentFiles, path]) + const closeDrawer = () => { + setIsDrawerOpen(false) + filesButtonRef.current?.focus() + } + const closeMenu = () => { + setIsMenuOpen(false) + menuButtonRef.current?.focus() + } + // A menu item: closes the menu, then acts. + const fromMenu = (action: () => void) => () => { + setIsMenuOpen(false) + action() } - const closeFile = (path: string) => { - if (openFiles.length === 1) return - const closingIndex = openFiles.indexOf(path) - const remainingFiles = openFiles.filter((openPath) => openPath !== path) - setOpenFiles(remainingFiles) - if (path === activeFile) setActiveFile(remainingFiles[Math.max(0, closingIndex - 1)]) + const selectLocale = (nextLocale: Locale) => { + setLocale(nextLocale) + saveLocale(nextLocale) } const renameProject = () => { @@ -77,29 +116,23 @@ function App() { const requestedPath = window.prompt(t.promptFileName, t.promptDefaultFileName)?.trim() if (!requestedPath || project.files[requestedPath]) return const kind: ProjectFileKind = requestedPath.endsWith('.py') ? 'python' : 'text' - const newFile = { path: requestedPath, label: requestedPath.replace(/\.[^/.]+$/, ''), kind, code: kind === 'python' ? '# Start writing Python here\n' : '' } + const newFile = { path: requestedPath, label: requestedPath.replace(/\.[^/.]+$/, ''), kind, code: '' } setProject((currentProject) => addProjectFile(currentProject, newFile)) - openFile(requestedPath) + setActiveFile(requestedPath) } const renameFile = (path: string) => { const requestedPath = window.prompt(t.promptRenameFile, path)?.trim() if (!requestedPath || requestedPath === path || project.files[requestedPath]) return setProject((currentProject) => renameProjectFile(currentProject, path, requestedPath)) - setOpenFiles((currentFiles) => currentFiles.map((openPath) => openPath === path ? requestedPath : openPath)) if (activeFile === path) setActiveFile(requestedPath) } const deleteFile = (path: string) => { if (Object.keys(project.files).length === 1 || !window.confirm(t.confirmDeleteFile(path))) return const remainingPaths = Object.keys(project.files).filter((filePath) => filePath !== path) - const nextActiveFile = activeFile === path ? remainingPaths[0] : activeFile setProject((currentProject) => deleteProjectFile(currentProject, path)) - setOpenFiles((currentFiles) => { - const remainingOpenFiles = currentFiles.filter((openPath) => openPath !== path) - return remainingOpenFiles.length ? remainingOpenFiles : [nextActiveFile] - }) - if (activeFile === path) setActiveFile(nextActiveFile) + if (activeFile === path) setActiveFile(remainingPaths[0]) } const downloadProject = async () => { @@ -112,10 +145,14 @@ function App() { URL.revokeObjectURL(url) } + // Teilen: the link to a copy of this project, copied with a short + // confirmation beside the button, or shown to copy by hand. const shareProject = async () => { const shareUrl = buildShareUrl(project) try { await navigator.clipboard.writeText(shareUrl) + setShareCopied(true) + window.setTimeout(() => setShareCopied(false), 3000) } catch { window.prompt(t.shareLinkPromptLabel, shareUrl) } @@ -130,7 +167,6 @@ function App() { } setProject(importedProject) setActiveFile(importedProject.mainFile) - setOpenFiles([importedProject.mainFile]) } catch { // Folder picker not supported or cancelled - importProjectFolder already // falls back to the hidden zip above when appropriate. @@ -141,7 +177,6 @@ function App() { const importedProject = await importProjectZip(file, project) setProject(importedProject) setActiveFile(importedProject.mainFile) - setOpenFiles([importedProject.mainFile]) } const resetLocalProject = () => { @@ -151,29 +186,79 @@ function App() { } const authErrorMessage = authErrorKey === 'init' ? t.authInitError : authErrorKey === 'config' ? t.authConfigError : authErrorKey === 'cancelled' ? t.authCancelledError : '' + const filePaths = Object.keys(project.files) return ( -
-
glpython
{t.openProject}
{ const file = event.target.files?.[0]; if (file) void openZip(file); event.target.value = '' }} />
{localeNames[locale]}
{(Object.keys(localeNames) as Locale[]).map((code) => )}
-
- {isSidebarOpen && } -
+
+
+
glpython
+
+
+ {shareCopied && {t.shareCopied}} + +
+ + {isMenuOpen &&
{ if (event.key === 'Escape') { event.preventDefault(); closeMenu() } else if (event.key === 'Tab') setIsMenuOpen(false); else moveMenuFocus(event, menuRef.current) }}> + +
+ + + +
+
+ + {(Object.keys(localeNames) as Locale[]).map((code) => )} +
+
+ + +
+
} +
+ { const file = event.target.files?.[0]; if (file) void openZip(file); event.target.value = '' }} /> +
+
+
+ + {isDrawerOpen && } +
{authErrorMessage &&
{authErrorMessage}
} undefined} onFileCodeChange={(path, code) => setProject((currentProject) => updateProjectFile(currentProject, path, code))} isGraphicsOpen={isGraphicsOpen} onGraphicsOpenChange={setIsGraphicsOpen} + actionsContainer={actionsContainer} + capabilities={{ breadcrumbs: false, closeFileTabs: false, resetOutput: false }} t={t} />
-
{t.statusRuntime}{t.footerTagline}
-
{t.devTools}
) } diff --git a/src/i18n.ts b/src/i18n.ts index 56930a1..29a1622 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -14,21 +14,25 @@ type ShellStrings = { openProject: string openFolder: string downloadProject: string - shareProject: string shareLinkCopiedOutput: string shareLinkPromptLabel: string + share: string + shareCopied: string + moreActions: string + files: string + closeFiles: string + projectName: string + ideTools: string + restartPython: string + projectMenu: string microsoftConnected: string signInWithMicrosoft: string settings: string notSignedIn: string - collapseSidebar: string - expandSidebar: string - courseCategory: string courseMenu: string renameProject: string promptRenameProject: string lessonProgress: string - projectFiles: string addFile: string renameFile: (path: string) => string deleteFile: (path: string) => string @@ -37,9 +41,7 @@ type ShellStrings = { pythonReference: string unsaved: string save: string - statusRuntime: string autosaveOff: string - footerTagline: string devTools: string clearLocalProject: string language: string @@ -68,21 +70,25 @@ export const translations: Record = { openProject: 'Open project', openFolder: 'Open folder', downloadProject: 'Download project', - shareProject: 'Share project', shareLinkCopiedOutput: 'Share link copied to the clipboard. Anyone who opens it gets a copy of this project.', shareLinkPromptLabel: 'Copy this share link:', + share: 'Share', + shareCopied: 'Link copied ✓', + moreActions: 'More actions', + files: 'Files', + closeFiles: 'Close files', + projectName: 'Project name', + ideTools: 'Tools', + restartPython: 'Restart Python', + projectMenu: 'Project', microsoftConnected: 'Microsoft connected', signInWithMicrosoft: 'Sign in with Microsoft', settings: 'Settings', notSignedIn: 'Not signed in', - collapseSidebar: 'Collapse files sidebar', - expandSidebar: 'Expand files sidebar', - courseCategory: 'PROJECT NAME', courseMenu: 'Course menu', renameProject: 'Rename project', promptRenameProject: 'Project name', lessonProgress: 'Lesson 01 of 08', - projectFiles: 'PROJECT FILES', addFile: 'Add file', renameFile: (path) => `Rename ${path}`, deleteFile: (path) => `Delete ${path}`, @@ -91,9 +97,7 @@ export const translations: Record = { pythonReference: 'Python reference', unsaved: 'Unsaved', save: 'Save', - statusRuntime: 'Python 3.12 in browser', autosaveOff: 'Autosave is off', - footerTagline: 'glpython preview · built for learning', devTools: 'Development tools', clearLocalProject: 'Clear local project', language: 'Language', @@ -118,21 +122,25 @@ export const translations: Record = { openProject: 'Projekt öffnen', openFolder: 'Ordner öffnen', downloadProject: 'Projekt herunterladen', - shareProject: 'Projekt teilen', shareLinkCopiedOutput: 'Freigabelink in die Zwischenablage kopiert. Wer ihn öffnet, erhält eine Kopie dieses Projekts.', shareLinkPromptLabel: 'Diesen Freigabelink kopieren:', + share: 'Teilen', + shareCopied: 'Link kopiert ✓', + moreActions: 'Weitere Aktionen', + files: 'Dateien', + closeFiles: 'Dateien schliessen', + projectName: 'Projektname', + ideTools: 'Werkzeuge', + restartPython: 'Python neu starten', + projectMenu: 'Projekt', microsoftConnected: 'Mit Microsoft verbunden', signInWithMicrosoft: 'Mit Microsoft anmelden', settings: 'Einstellungen', notSignedIn: 'Nicht angemeldet', - collapseSidebar: 'Dateileiste einklappen', - expandSidebar: 'Dateileiste ausklappen', - courseCategory: 'PROJEKTNAME', courseMenu: 'Kursmenü', renameProject: 'Projekt umbenennen', promptRenameProject: 'Projektname', lessonProgress: 'Lektion 01 von 08', - projectFiles: 'PROJEKTDATEIEN', addFile: 'Datei hinzufügen', renameFile: (path) => `${path} umbenennen`, deleteFile: (path) => `${path} löschen`, @@ -141,9 +149,7 @@ export const translations: Record = { pythonReference: 'Python-Referenz', unsaved: 'Nicht gespeichert', save: 'Speichern', - statusRuntime: 'Python 3.12 im Browser', autosaveOff: 'Automatisches Speichern ist deaktiviert', - footerTagline: 'glpython-Vorschau · zum Lernen entwickelt', devTools: 'Entwicklerwerkzeuge', clearLocalProject: 'Lokales Projekt löschen', language: 'Sprache', diff --git a/src/index.css b/src/index.css index a91d28a..1df228c 100644 --- a/src/index.css +++ b/src/index.css @@ -3,3 +3,48 @@ * { box-sizing: border-box; } body { margin: 0; min-width: 320px; } button { font: inherit; } + +/* The shell (P18): one bar on top, a rail with the files drawer and the + Python reference at the left edge, and the workspace filling the rest of + the window (its stacked layout: editor, divider, console). */ +.ide { height: 100vh; height: 100dvh; display: flex; flex-direction: column; background: var(--glp-bg); } +.ide :is(button, a, input, [role="separator"]):focus-visible { outline: 2px solid var(--glp-accent); outline-offset: 2px; } +.ide-bar { min-height: 56px; flex-shrink: 0; display: flex; align-items: center; gap: 16px; padding: 8px 16px; border-bottom: 1px solid var(--glp-border); } +.ide-brand { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 16px; } +.ide-brand-mark { width: 28px; height: 28px; display: grid; place-items: center; color: var(--glp-on-accent); background: var(--glp-accent); border-radius: var(--glp-radius); } +.ide-actions { display: flex; } +.ide-bar-end { margin-left: auto; display: flex; align-items: center; gap: 8px; } +.ide-share-status { color: var(--glp-text-muted); font-size: 13px; } +.ide-menu { position: relative; } +.ide-menu-list { position: absolute; top: calc(100% + 6px); right: 0; z-index: 20; min-width: 250px; padding: 6px 0; background: var(--glp-bg); border: 1px solid var(--glp-border); border-radius: var(--glp-radius); box-shadow: 0 8px 20px rgba(0, 0, 0, .12); } +.ide-menu-list button { width: 100%; display: flex; align-items: center; gap: 10px; padding: 8px 14px; border: 0; color: var(--glp-text); background: none; text-align: left; font: 14px/1.3 var(--glp-font-ui); cursor: pointer; } +.ide-menu-list button:hover, .ide-menu-list button:focus-visible { background: var(--glp-surface); outline: none; } +.ide-menu-group { margin-top: 4px; padding-top: 4px; border-top: 1px solid var(--glp-border); } +.ide-menu-label { display: block; padding: 6px 14px 2px; color: var(--glp-text-muted); font-size: 12px; font-weight: 600; } +.ide-menu-check { width: 15px; text-align: center; } +[aria-checked="true"] > .ide-menu-check::before { content: "✓"; } +.ide-menu-danger { color: var(--glp-error) !important; } +.ide-body { position: relative; min-height: 0; flex: 1; display: flex; } +.ide-rail { width: 52px; flex-shrink: 0; display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 12px 0; border-right: 1px solid var(--glp-border); } +.ide-rail .icon-button { width: 38px; height: 38px; } +/* Over the workspace, so nothing below moves when it opens. */ +.ide-drawer { position: absolute; top: 0; bottom: 0; left: 52px; z-index: 15; width: 290px; display: flex; flex-direction: column; gap: 14px; padding: 14px; overflow-y: auto; background: var(--glp-bg); border-right: 1px solid var(--glp-border); box-shadow: 8px 0 20px rgba(0, 0, 0, .08); } +.ide-drawer-head { display: flex; align-items: center; justify-content: space-between; } +.ide-drawer-title { font-weight: 700; } +.ide-project-label { display: block; color: var(--glp-text-muted); font-size: 12px; font-weight: 600; } +.ide-project-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.ide-file-list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 2px; } +.ide-file-list li { display: flex; align-items: center; gap: 2px; border-radius: var(--glp-radius); } +.ide-file-list li.active { background: var(--glp-surface); } +.ide-file-open { min-width: 0; flex: 1; display: flex; align-items: center; gap: 8px; padding: 7px 8px; border: 0; color: var(--glp-text); background: none; text-align: left; font: 14px var(--glp-font-code); cursor: pointer; } +.ide-file-open span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ide-file-delete:not(:disabled):hover { color: var(--glp-error); } +.ide-new-file { align-self: flex-start; } +.ide-main { min-width: 0; min-height: 0; flex: 1; display: flex; flex-direction: column; gap: 10px; padding: 14px 16px 16px; } + +/* iPad portrait and narrower: the logo without its name. */ +@media (max-width: 900px) { + .ide-brand > span:last-child { display: none; } + .ide-bar { gap: 10px; padding: 8px 12px; } + .ide-main { padding: 12px; } +}