Repository navigation
feat: Lumen OS — a desktop environment in TypeScript, for the browser and the desktop - #1
Open
lumenpearson wants to merge 176 commits into
Open
lumenpearson wants to merge 176 commits into
lumenpearson wants to merge 176 commits into
Conversation
Sets up the repository for a desktop operating environment written in TypeScript and React 19 that runs both in a browser and inside Tauri 2 on Windows. Tooling: pnpm workspaces + turborepo, Biome, Vitest, husky with commitlint and lint-staged, GitHub Actions for CI and the Windows desktop build, Dependabot, issue and pull-request templates, and the vendored kill-ai-slop design-review skill. Packages: - tokens: colour, type, space and motion tokens as a Tailwind 4 theme - vfs: path helpers, typed errors, and OPFS / IndexedDB / memory adapters - platform: the host bridge with web and Tauri implementations - kernel: processes, windows, app registry, settings, users with PBKDF2 password hashing and recovery keys, notifications, clipboard and logging - ui: atomic component library over the tokens - apps: the app SDK, the HTML pseudo-program host, and the built-in apps - shell: boot, first-run assistant, lock screen with recovery, desktop, menubar, taskbar, Start menu, window manager, Spotlight, Control Center, notifications, Mission Control, custom cursor and screensaver Hosts: apps/web (Vite, PWA) and apps/desktop (Tauri) share the shell; crates/lumen-kernel provides the sandboxed file system and system information for the desktop build. Documentation: README, ARCHITECTURE, CONTRIBUTING, CODE_OF_CONDUCT, SECURITY, AI_USAGE_POLICY, plus .claude/ and .agents/ role definitions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
…ock field The wait cursor's arc carried transform-origin: 50% 50%, which for an SVG path resolves against the path's own bounding box rather than the viewBox, so the arc orbited instead of turning in place. transform-box: view-box with an explicit 12px 12px origin fixes it. The lock screen's password field was a max-radius pill with a backdrop blur; it is now the same radius as every other control, with an opaque-enough tint for legibility over a wallpaper. Also registers the jest-dom matcher types for packages that assert on the DOM, adds a shell integration test that boots the OS to setup and to the desktop, and annotates the design-scan hits that are deliberate (dots are round, the wordmark is the wordmark) with their reasons. pnpm deslop is clean for the shell. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
Checkpoint of the built-in applications while the remaining ones are still being written: - files: the explorer — list, grid and column views, sidebar with favourites and locations, breadcrumb navigation with history, rename in place, drag and drop, context menus, Get Info and Quick Look - sheets: the spreadsheet engine — A1 references, tokenizer, parser, evaluator with dependency tracking and cycle detection, the function library, CSV import and export, and display formatting - terminal: the shell — tokenizer with quoting, variable and command substitution, pipes, redirection, globbing, and the command set - editor: plain text and code editing with a Markdown renderer Also carries the Rust side's command wiring and Tauri configuration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
The Rust job failed on a call to a helper that had not been written yet; cargo fmt, clippy -D warnings and the 46 workspace tests are green now. Lint failed at the first package and never reached the rest. Fixed across the nine packages I own, in four kinds: - Biome was linting standalone .svg assets as if they were JSX. SVG files are assets, not source: excluded them. - Accessibility findings that were worth taking seriously: the cursor and window-control glyphs are decorative and now say so with aria-hidden; the desktop icon grid and the Spotlight results are proper listboxes, with Spotlight following the combobox pattern (the input keeps focus and points at the active option through aria-activedescendant); a menu separator is now an <hr>, which means exactly that; the battery readout is a labelled image. - Comma operators in click handlers became statement bodies. - Where a rule is wrong for the code, a suppression carries the reason: a native checkbox maps its checked state to aria-checked, a grid container owns focus so its rows are not tab stops, the path validator matches control characters on purpose, and three effects key on a narrower value than the whole object by design. Also orders the menu-shortcut CSS so specificity ascends. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
Adds the word processor, the text and code editor, and the spreadsheet view on top of the engine that landed earlier, alongside continued work on Files, Sheets and Terminal. Narrows two list-rendering loops in Writer through the existing isElement guard: the tag check proves the node is an element, but TypeScript cannot see that through the tagOf helper, so exporting Markdown and counting words both failed to compile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
Sheets ships a real spreadsheet engine: A1/range refs with absolute markers, a precedence-climbing formula parser, 60 functions, memoised evaluation with cycle detection, and ref rewriting for fill, paste and row/column structure edits. Grid rendering is virtualised with sticky headers and rAF-driven drags. Editor and Writer round out the office set, with Writer exporting to Markdown and plain text. registry.ts now lists the seven built-ins in launcher order. Three product bugs found while running the system rather than the tests: - Toggle: the input carried `sr-only`, so its decorative span sat on top of it and swallowed pointer events. The control looked right and could not be clicked. It is now a transparent overlay that is the hit target. - LockScreen: the password hint was gated on `!error`, but a wrong attempt always sets an error, so the hint could never appear. It now sits alongside the error, which is exactly when it is wanted. - Writer: list rendering narrowed on the tag string without narrowing the node, so `renderList` was reached with a non-Element. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
…ktop The Dawn wallpaper drew its sun after the ground rect, so the whole disc sat on top of the horizon with the horizon line running through its middle. It read as a rendering artifact rather than a scene. Drawing the sun before the ground lets the ground occlude it, which is both what a horizon does and what makes the shape deliberate. The show-desktop strip at the end of the taskbar carried a LayoutGrid glyph at 12px. That icon is four squares with gaps between them, so at that size it resolves to a grey smudge in the screen corner — debris, not an affordance. The strip now carries no glyph: the hairline divider marks it, the tooltip and aria-label name it, and it takes a hover tint so it still answers the pointer. Verified by rebuilding and re-running the Playwright suite (4 passed) and inspecting the corner at 10x. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
All 25 remaining hits were the scanner matching a shape rather than a fault, so each is suppressed at the narrowest scope with the reason in the comment rather than silenced globally: - Round shapes that are the shape itself: the slider track and thumb, the switch knob, an avatar, a count badge, a progress bar's caps, and two spinner rings. - The menu-shortcut column in JetBrains Mono, which design rule 1 asks for by name and which needs the fixed advance to stay aligned. - The menu's backdrop blur, which is already gated behind the opt-in transparency setting with an opaque surface as the default. - U+21A9 for Return, printed only on macOS, where it is what the platform puts in its own menus. - The wallpaper SVG, which is artwork and the point of the module. - A dialog whose radius and border sit on one element, where the clipping is there so the header and footer rules stop at the curve. packages/ui, packages/kernel, packages/shell and apps/landing all scan clean. Left as-is: `.lumen-spin` already pins its rotation centre outside the keyframe, so the spinner does not wobble. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
An earlier commit staged this folder while it was still being written, so index.tsx reached the repository importing a ./Slides module that did not exist yet and CI failed on @lumen/apps#typecheck. This commits the rest of the app: the editor shell, slide canvas, slide list, inspector, bullet editing and presenter view, alongside the deck model and exporter that were already in. Verified: tsc reports nothing under src/slides, biome is clean over all 13 files, and vitest runs 49 tests green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
…t between tests Two faults, both mine, both surfaced by CI on @lumen/web#test. apps/web had no Vitest config, so Vitest ran on its defaults and globbed every `*.spec.ts` in the package — including `e2e/os.spec.ts`, which is a Playwright suite and throws on its first `test.describe`. Every other package already uses the shared config, whose `include` is scoped to `src/`; apps/web now does too, so the two runners stop competing for the same glob. `passWithNoTests` is deliberate rather than a placeholder test: `src/` there is a thirteen-line entry point and the logic it mounts is tested in the packages that own it. The second is older and worse. The shared config keeps `globals: false`, and Testing Library only registers its automatic cleanup when globals are on — so no rendered tree was ever unmounted. Trees accumulated in one document, and because the kernel stores are module state, the stale trees kept re-rendering as later tests drove them. The shell's boot test failed on `Found multiple elements by: [data-testid="menubar"]`: the first test's OS was still mounted and had followed the second test's session state to the desktop. It looked like a flake because it depended on when that stale tree re-rendered. Registering `afterEach(cleanup)` in the shared setup fixes it for every package at once. Verified with the CI commands: lint 10/10, typecheck 10/10, build 3/3, and `pnpm test` 8/8 three times over with the turbo cache cleared between runs — it had failed three times running before this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
…elves
The whole-repo scan is advisory in CI, which makes permanent false
positives worse than useless: a report that always has noise in it is a
report nobody reads. Every hit outside the in-flight app folders was the
scanner matching a definition rather than a misuse, so each is suppressed
where it sits with the reason written down.
- `code, kbd, samp, pre { font-family: var(--font-mono) }` is the "mono
for code only" rule, not a breach of it.
- The `mono` utility is what design rule 1 is implemented with; defining
it is not overusing it.
- `surface-blur` is applied only where Settings has transparency on, and
is dropped under Reduce Transparency. The default chrome is opaque.
- `--ease-spring` is the curve design rule 5 names for window open/close
and menu reveal. Nothing on hover uses it.
- The fallback app-icon tile prints a single initial — a letterform
standing in for an icon is the accent case, not body copy.
- CLAUDE.md line 60 was flagged for quoting the copy pattern it forbids.
packages/tokens, ui, kernel, shell and apps/landing now scan clean. The
six remaining hits are all inside app folders being written right now and
are left to those agents.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
The panel header was an <h2> inside a window with no <h1>, so it put a lone level into the document outline. The <aside> around it already carries aria-label="Slide properties", which is what names the landmark for assistive technology, so the heading added structure without adding navigation. A span keeps the visual weight and drops the false level. Verified: tsc reports nothing under src/slides, biome clean over 13 files, 49 tests green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
kind.ts maps an extension or MIME type to the viewer that should open it. This extends the table and its cases while the rest of the app is still being written; the module has no imports from the unfinished parts, so it stands on its own. Verified in isolation: tsc reports nothing for these files, biome clean, 22 tests green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
navigation.ts held the per-tab back/forward stack as a separate module. Keeping it apart from tabs.ts meant the two had to agree on the shape of a tab from opposite sides, so the history stack now lives with the tab state that owns it. Nothing imports the removed module. The rest of the Browser is still being written and stays out of the repository until it is whole. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
The fan-out that was building these was cut short by a rate limit. Their
pure logic is finished and stands on its own — no entry point exists yet,
so nothing in the registry imports them and they are inert in the build.
Committing them now keeps the work out of a container that gets reclaimed.
Media: queue reducer with deterministic shuffle ordering, time
formatting, the frequency-bin mapping the visualiser draws from, the key
map, and error classification. 92 tests.
System Information: probes that report {value, available, reason} so an
unavailable metric renders an em-dash and its reason rather than a
plausible fake, plus section assembly and the text report. 96 tests.
Task Manager: the fixed-capacity sample ring, column comparators, process
projection and metric samplers, with the three tab components.
Still owed on Task Manager: it has no tests. The repository's rule is
that logic without tests is not done, and this is not done — the ring
buffer's wraparound and the comparators are exactly what needs pinning.
The interface for all three is unwritten; none is registered as an app.
Verified: tsc reports nothing under these three folders, and vitest runs
188 tests green across media and sysinfo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
Adds the entry point and the interface over the probe layer that landed earlier: the overview hero, the processor, memory, graphics, storage, software and uptime sections, the storage bar, and Copy/Save Report. The app's whole value is that every line in it is true, so an unavailable metric is a first-class state rather than a gap to fill: each row renders an em-dash and the reason its probe could not answer — WebGL's renderer string being blocked, `performance.memory` existing only on Chromium, a native field that has no web equivalent. The measured refresh rate is labelled as measured. Verified: tsc reports nothing under src/sysinfo, 8 test files green, and the design scan finds no signals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
Browser: tabs, an address bar implementing the ARIA combobox pattern over history and favourites, per-tab back/forward, and the lumen:// pages rendered as React rather than HTML strings. External pages load in a sandboxed frame, which cannot be read across origins — so the app shows the hostname until a real title is known instead of inventing one, and offers an honest panel when a site refuses to be framed. There is no find-in-page, because it is not possible across origins and a fake one would be worse than its absence. Preview: the universal viewer. Images with rAF-driven pan and zoom, PDFs through an embed with no fabricated page count, an accessible JSON tree, CSV, Markdown, media with the app's own transport, and a virtualised hex dump for anything else. Arrow keys step through the sibling files in the folder that the same viewer can open. Task Manager: processes, performance and apps, reading the kernel's own process and window stores, the platform layer, and rAF deltas for a genuinely measured frame rate. Where a metric has no source on the current platform it prints an em-dash and why, never a plausible number. It now carries the tests it owed two commits ago: the sample ring's wraparound and capacity, and the column comparators. Verified per folder: tsc silent, biome clean, 23 test files green across the three, and the design scan finds no signals in any of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
…m Information These five were built but unreachable: nothing imported them, so they were inert in the build and absent from the Start menu, Spotlight and the file-type associations. Registering them is what turns them into apps. Launcher order now runs from what people reach for first — Files, Browser, Terminal — through the office set, then Preview, then the system tools. Verified: the web app builds, and the Playwright suite passes 4/4 against it, which exercises boot, setup, lock and unlock, the Start menu, search and window management with the wider registry loaded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
SystemInfo.tsx passed a `failure` prop to a component whose props type did not declare it, so @lumen/apps#typecheck failed on three commits in a row. The cause was mine and not the code's: I committed the folder while it was still being written, capturing one file after a change and its counterpart before. Local typecheck did not catch it because the working tree also holds folders that are not in the repository, so tsc there is not compiling what CI compiles. This snapshot was verified differently: staged first, then checked that `git diff` for the folder is empty, so the content I typechecked is exactly the content being committed, and that nothing in the folder imports across folder boundaries. The two typecheck errors that remain in the working tree are in src/calculator and src/notes, both untracked and still being written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
Adds the PDF, text, JSON, CSV, media, hex, SVG and unsupported viewers behind the shell, plus the keyboard playback mapping and the persisted filmstrip preference. Two decisions worth recording. The PDF viewer is an <object> whose children are the fallback panel, because that is the mechanism a browser actually invokes when it has no reader — and it shows no page count or page controls, since neither can be read from outside the embed and a guessed one would be a lie. Markdown reuses the Editor's renderer rather than becoming the third copy in this package; nothing here builds HTML from a string. Full screen delegates to the app's own presentation mode rather than the element's browser fullscreen, so the F key, the View menu and the player's own button all mean the same thing and Escape always exits. Verified: tsc silent for the folder, 10 test files and 185 tests green, staged before checking so the verified content is the committed content. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
Notes: Markdown notes as real .md files under Documents/Notes, so Files sees them and the Editor opens them. The renderer builds React elements rather than HTML from a string, drops javascript: and data: hrefs, and carries line positions so ticking a task box in preview rewrites that line in the file. Three panes collapse to two and then to one as the window narrows. Media Player: its own transport rather than the browser's controls, with the seek bar committing currentTime only on pointer-up while previewing through refs in requestAnimationFrame. The visualiser is driven by a real AnalyserNode and hides itself when no audio context can be created, rather than animating something that means nothing. No ID3 parsing and no invented artist or album — the file name is what is known. Calculator: basic, scientific and programmer modes over a precedence-climbing parser rather than eval, with BigInt and word-size masking so 64-bit values are exact. The display rounds to a significant- digit budget so 0.1 + 0.2 reads 0.3 without pretending to arbitrary precision. Also finishes the Browser's internal pages. Verified per folder before staging, and staged before the final check so the verified content is the committed content: tsc silent across the whole package, biome clean, 31 test files green across the four, and the design scan finds no signals in any of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
All fifteen built-ins are now reachable from the Start menu, Spotlight and file associations. This also resolves the two forward references in the default taskbar pins, which named lumen.browser and lumen.notes before either existed — they were filtered out silently, which is why the taskbar has been short. Verified: tsc silent across the package, the web app builds, and the Playwright suite passes 4/4 with all fifteen apps loaded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
Media Player keeps one video element for every track, because an element can carry only one MediaElementAudioSourceNode and swapping between an audio and a video tag per track would strand the analyser. For an audio track the stage hides and Now Playing takes its place. A refused autoplay is not reported as a failure — the play button is right there — while decode and read errors become an honest panel naming the file and the browser's own message. Notes lands with 192 tests and seven real bugs its own tests caught: - View > Show Tags could never restore the rail: the handler read `!handlers.current.setPref.length === false`, which is always false. - Launch opened notes[0] in directory order while the list showed sorted order, so the highlighted row and the open note disagreed. - `lineSpan` with the caret at offset 0 of a document starting with a newline spliced one character late and corrupted the text. - `toggleList` kept an indent inside the bare text, turning " a" into " 1. a". - Front matter quoted any value containing a colon, rewriting a stored ISO timestamp on the first save of an untouched file. - `fileNameForTitle` held raw U+0000–U+001F bytes in a regex literal. - The empty state hard-coded "Press Cmd+N", which is wrong on Windows. Verified: tsc silent across the package, biome clean, 17 test files green across the two, and the design scan finds no signals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
…so binds useGlobalShortcuts listens in the capture phase and calls stopPropagation, so an app that bound the same chord never saw the key: the shell closed the whole window on Mod+W before a browser could close its tab. Any app wanting Mod+W had to fight the shell for it, and the Browser did — with a capture listener of its own plus a close guard that resolved a turn later to withdraw the close. That worked, and it was the wrong shape. The shell now asks the focused window's menus whether they bind the chord and stands aside when they do, but only for the window.* actions. Close, quit, minimise and snap are reinterpretable — Mod+W means "close this tab" in a browser and "close this window" everywhere else. Spotlight, lock and Mission Control are the system's and stay unclaimable, so an app cannot capture the key that gets you out of it. The menu walk this needs already existed inside the app SDK. It moves to the kernel beside matchesShortcut and the menu store, because two copies answering "which item owns this chord" would drift and the shell would start swallowing keys apps believed they owned. A disabled item does not claim its chord: the command is off, so the shell should still act. The Browser's workaround is gone; its Close Tab menu item is enough, and its existing closeTab already closes the window on the last tab. Verified: lint 10/10, typecheck 10/10, test 8/8, build 3/3, with 8 new tests pinning the precedence rule — including that Mod+W under a ctrl preference must not match a meta press, or the shell would yield a chord the app never claimed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
Adds the shell, display, keypad, tape and bases view over the parser that landed earlier, with 209 tests including a mounted app: DEG to RAD moving sin(90) from 1 to 0.893996663601, the 2nd key inverting the trig row, digits disabling outside the current base, 8-bit masking, exact 64-bit wrap-around, and the settings file round trip. The display measures its own width and picks a font size from it, so a long value shrinks rather than clipping. The keypad is a grid of real buttons on minmax(0,1fr) tracks, so it divides the window instead of overflowing at the 260px minimum. Two fixes to existing files were needed to make the folder pass: the programmer layout's array literal had no contextual type, so `action: 'not'` widened to string and the whole table stopped satisfying KeyDef[]; and two optional-chain warnings in the parser. Verified: tsc silent, biome clean over 23 files, 9 test files and 209 tests green, design scan clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
…written Two app agents hit this independently and each worked around it in their own app, which is the signal it belonged in the SDK. The load effect called setValue unconditionally when the read settled. A value written between the read starting and the read landing was therefore thrown away — and the catch was worse than the success path, because a missing file is the ordinary first-run case: it reset the state to the fallback while the debounced write of the new value still reached disk, so the screen and the file disagreed. A launch argument, or a click in the first few milliseconds, was enough to trigger it. A write now marks the path as written and the read stands aside, in both its resolve and its reject. The tests gate readJson on a promise the test releases by hand. That gate is the fix's proof, not decoration: my first attempt used the in-memory VFS directly, which answers in a microtask, so the click always landed after the read and both tests passed with the fix removed. With the gate, both fail without it and pass with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
The Windows installer build failed on: [MISSING_EXPORT] "Visualiser" is not exported by "packages/apps/src/media/Visualiser.ts" Note the extension in the message. The folder held Visualiser.tsx, the canvas component, beside visualiser.ts, the frequency-bin maths. On Linux those are two files and `import './Visualiser'` resolves to exactly one. On a case-insensitive filesystem the bundler asked for './Visualiser' and was handed the maths module, which exports no such component. Three folders had this shape and only one had been caught, because only one of the pairs was imported by the extensionless name that triggers it: - media/Visualiser.tsx -> VisualiserCanvas.tsx (beside visualiser.ts) - calculator/Bases.tsx -> BasesPanel.tsx (beside bases.ts) - notes/notes.ts -> library.ts (beside Notes.tsx) The components were renamed where they had one importer each; for Notes the model moved instead, so the app root keeps the name every other app root uses. This class of bug is invisible to every check that runs on Linux, which is all of them except the installer. `pnpm check:portability` now looks for module basenames in one directory that differ only by case, and runs in CI before lint — so the platform builds are no longer the thing that finds it. Verified by recreating the exact collision: the guard prints both paths and exits 1. `pnpm --filter @lumen/desktop build` now succeeds, which is the build that was failing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
… action The repository had two issue templates, a PR template, CODEOWNERS and dependabot. This fills in what was actually missing rather than adding files for their own sake: - `actions/setup` — a composite action for pnpm, Node from .nvmrc and the store cache, which both CI jobs had copied verbatim. Two copies of a Node version drift silently and show up only as a cache that stops hitting, so it is defined once and used twice. - `codeql.yml` — security-and-quality analysis for JavaScript/TypeScript and for the workflows themselves, on push, on pull request and weekly, so something disclosed later still surfaces against untouched code. Rust is deliberately excluded: CodeQL's Rust support is in preview, and clippy at -D warnings plus the crate's tests already run on every change. A preview analyser would add noise, not findings. - `labeler.yml` and its workflow — labels a pull request by the layer it touches, which in a monorepo with a strict dependency direction is the first thing a reviewer needs to know. The workflow uses pull_request_target, so it takes the narrowest permissions that do the job and never checks out or runs the pull request's code. - `ISSUE_TEMPLATE/config.yml` — turns off blank issues and points questions at Discussions and vulnerabilities at a private advisory. - `SUPPORT.md` — where to put what, and what makes a report useful in a project whose two hosts differ enough that "which host" is the question most reports forget to answer. The bug report's host dropdown offered only "Desktop (Tauri, Windows)". It now lists Windows, macOS and Linux separately, matching where the desktop build is going. Every workflow and template parses as YAML. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…ext start This is the piece that makes a silent update defensible. The interface says when it has drawn itself; a live version that has never said so did not work, so far as anything here can tell, and the next start puts the pointer back to what it replaced — or removes it, which serves the bundle inside the binary. Without it one bad patch is unrecoverable on every machine at once, and there is nobody to walk over and fix it. The marker is a file per version rather than a field in the pointer, because a rollback rewrites the pointer and the fact that a version once worked has to outlive that.
…opy as the floor The window now opens on a `lumen://` scheme the host answers itself: the applied version under the data directory first, the bundle compiled into the binary second, a 404 third. That is the whole of what a later stage needs in order to replace the interface without replacing the executable. Three things here are not arbitrary. The store is settled and managed before the window exists, because the window's first request is for the interface and the handler answers it out of that state. Set up afterwards, an applied version would be ignored for exactly one start — the kind of bug that only appears on someone else's machine. The address is decided in Rust and everything else about the window stays in `tauri.conf.json` behind `create: false`. WebView2 cannot navigate a scheme it does not know, so wry serves custom protocols from `http://<scheme>.localhost` on Windows and Android and from the scheme itself elsewhere; writing either one in the configuration file would leave the other platforms opening a dead address, and writing both would mean two copies of every window property drifting apart. The shell reports that it drew itself only once the boot screen has given way to the interface. Reporting earlier would vouch for a version that had not yet drawn anything, which is the one thing the report is for.
The separators in the system menu read as solid near-black lines. The
theme was never the reason:
HR.lumen-menu-separator :: background rgba(0, 0, 0, 0.09)
border-top rgb(20, 21, 23)
Preflight gives every `hr` a 1px top border in `currentColor`, and this
element is 1px tall, so that opaque line in the ink colour sat exactly
where the translucent one was drawn and covered it. What was on screen
was the reset's separator; the theme's had never been visible.
`border: 0` and the hairline is the token again — 9 % of the ink, dark
on light and light on dark, and quiet enough to let a blurred surface
show what is behind it. `Divider` already carried `border-0`, which is
why only the menus were wrong.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
Sites fetched through Lumen came up blank — vercel.com and MDN both,
intermittently, which is the worst kind. The fetch was never the
problem: the endpoint returns 524 KB with 3,758 characters of body text
for vercel.com. The frame threw it away.
Measured in Chromium, same URL, same frame:
site scripts kept, blanked html 10,203 text 0 no <body>
site scripts kept, survived html 524,418 text 1,205 19 children
site scripts stripped html 258,484 text 1,205 4 children
A relayed page is sandboxed to an opaque origin, so the site's scripts
cannot do their job here: `document.cookie` and `sessionStorage` throw
on first access, its own API calls are refused by its CORS rules, and
nothing can be signed in to — the banner over the page says exactly
that. What they can still do is take the document apart, and the first
row above is one doing it: 524 KB reduced to 10 KB with no body left.
So they are taken out before the page is served, and Lumen's own
one-line ready signal is injected after. Four samples across both
orders with the strip in place: identical, and none of them empty.
The regex ends an element at its first closing tag exactly as the HTML
parser does. Inline `on*` handlers stay — they fire only on
interaction, and removing attributes needs a parser, not a pattern.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
…racter sanitization' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
CodeQL, on the strip added in 4a5760b — two high-severity alerts, both right: Bad HTML filtering regexp — this regular expression does not match script end tags like </script\t\n bar> Incomplete multi-character sanitization — this string may still contain <script Both are real. `</script\s*>` is not how an element ends: a browser closes it at `</script bar>`, at `</script\t\n>` and at `</script/>` alike, and a `<script>` that is never closed is script data to the end of the file. A page could write either shape and keep the script it was told had been removed. So the pattern is gone. The scanner that replaces it ends an element exactly where the parser's script-data state does, minds a `>` inside a quoted attribute, and drops the remainder of the document after an unterminated script — the same thing the parser does with it. And the guarantee no longer rests on string surgery being right. Each page is served under `script-src 'nonce-…'; object-src 'none'` with a fresh nonce, carried by the one script Lumen injects. Measured in Chromium on a relayed page: Lumen's signal arrives and no violation is logged; a script added with no nonce does not run; one added with a wrong nonce does not run either. Nine tests over the shapes above, two of which fail on the old pattern and pass on this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
Copilot Autofix pushed 1551ec0 for the same CodeQL finding while the scanner was being written. Its fix runs the old pattern in a loop until the output stops changing. That answers one of the two alerts — incomplete sanitization, where a removal reveals a tag the first pass could not see — but not the other. The pattern it loops is still the one CodeQL called a bad HTML filtering regexp: it ends an element only at `</script\s*>`, so `</script bar>` and `</script\t\n>` do not close one for it, and a script that is never closed is not removed however many times the loop runs. So the scanner stands and the loop goes. It ends an element where the parser's script-data state does, which is what both alerts were asking for, and the nonce policy on the response stands behind it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo
Found by applying a version by hand and starting the desktop, which is what
that step in the plan is for. The host said:
lumen: interface 0.9.9 never reported a load; went back
on the first start, before the version had been served at all. `settle` gave
up on any live version without a boot marker — and a version that has just
been applied has of course never reported one, so it was discarded before it
could run. No patch could ever have taken effect.
Probation now tells "being tried" apart from "was tried and failed". A
version with no marker at all is written an attempt marker and served: that
is its one chance. A version whose attempt marker is still there at the next
start did not answer, and only then is it given up. Reporting a boot removes
the attempt marker, so a version that works does not carry the evidence of a
start that did not.
Verified by applying a bundle by hand again: `.booted-0.9.10` appears and the
pointer survives, which is the whole chain in one file — the scheme handler
served from disk, the interface ran, its IPC reached the host, and the host
recorded it.
The boot screen and the lock screen are the first thing anyone sees and were the last thing written in one language only. Seventeen keys, and the scanner now holds both files to it. Two details worth the words. The power entries here are `lock.*` rather than the `system.*` ones that read the same in English: from the lock screen they act at once, and the system menu's carry the ellipsis that says they ask first — the same word, a different promise. And the boot screen is drawn before the settings file has been read, so it speaks whatever the store starts on rather than what is on disk; that is the honest limit of translating a screen shown before the system knows who is in front of it, and it is written down beside the code rather than left to be discovered. The scanner earned its place immediately: it caught "Switch User" and "Power" after I had already read the file twice.
…gree with it Four more files under the scanner, and the piece the rest of the translation needs: counts. English has two plural forms and Russian four. The recycle bin says how much is in it, and a dictionary that knows only a singular and a plural writes «5 объект» — the kind of thing that makes a translated interface read as a translation rather than as the interface. So a key can now name a family — `.one`, `.few`, `.many`, `.other`, the names Intl uses — and `plural()` picks the form `Intl.PluralRules` selects for the language in force. `.other` is the form every language has and the one anything missing falls back to. `PluralKey` is derived from the dictionary, so a base nobody wrote is a compile error like any other key. It goes through a helper type rather than testing `MessageKey` directly, because a conditional type only distributes over a union when what it tests is a naked type parameter — written the obvious way it quietly resolved to `never` and accepted nothing at all. Tested against the four cases Russian actually distinguishes: 1 объект, 2 объекта, 5 объектов, 21 объект.
The desktop and its context menu, the Start menu, Spotlight, the window menu, notifications, the control centre, the window switcher and the power screen. Nineteen files under the scanner now, and 168 keys. Two things the scanner is owed for. It found twenty-two strings a careful grep over the same files had missed — a `label=` prop, a heading sitting on its own line, text after an icon — which is the difference between a check that parses and a check that matches text. And it refused the desktop's sort and size submenus outright: their labels were built by capitalising the option's own value, which can be neither typechecked nor found, and would have produced a capitalised English word inside a Russian menu however much of the rest was translated. Each option names its own key now. Wi-Fi and Bluetooth join the same-in-both list. They are trademarks and are written that way in Russian too; the list exists so that a real missing translation cannot hide behind a coincidence.
311 labels across all 33 `menus.ts` modules, on 65 shared keys. The menus are data rather than components, so they read the imperative `t` — one key per label, so "Close" cannot become two different Russian words in two windows. Also, a real defect the work walked into. `formatMoney` built its formatter with `Intl.NumberFormat(undefined, …)`, which takes the *machine's* locale — so the price beside an interface set to English printed as `5,00 £` on a Russian Windows. It was the one number on the page that ignored the region setting above it. Money now follows the region Lumen is set to. Four tests were pinned to `£5.00` and had been failing on this machine since before any of this work; they passed in CI because CI is English. They name a locale now. A test that only holds where the developer happens to live is useless exactly where it would have helped.
Files, Browser, Text Editor, Preview, Photos, Writer, Sheets and Notes, on their own keys rather than the shared ones: "Size" in a file list and "Size" on a font are the same English word and two different Russian ones, which is why each application names its own even where the source text repeats. 97 more keys, 330 in all. 182 menu labels left, across the remaining twenty-five applications.
The last 182 labels, across the remaining twenty-five applications. All 590 menu labels in all 33 `menus.ts` modules now go through the dictionary, and all 33 are on the scanner's list, so they stay that way. 516 keys. The scanner caught the store's four menu group titles on the way in — `title:` rather than `label:`, which is why reading the files by eye had missed them twice. 52 files translated in total.
…nguage Taskbar & Menubar, Display, Animation and Appearance — 623 keys, 56 files under the scanner. The option tables here were `const` arrays at module scope. Filled in with `t()` where they stood, they would have taken whatever language the settings held when the module first loaded and kept those words after the language changed — a bug that only shows itself to the one person who switches language, which is to say the person the work is for. They are functions of the translator now, called during render, and the pages pass the `t` their own hook gives them, so the words follow. `Translate` is exported from the kernel for exactly that shape. Two strings are marked rather than translated, with the reason beside them: the product's name, and `px`.
676 keys, 58 files. Both pages carry text inside dialogs and helper components rather than only in the page body, so each of those gets its own translator rather than one being threaded down from the page. Three strings are marked rather than translated, with the reason beside each: the product's name in two places, and the `OS` that labels its version.
881 keys, 72 files. Every page of Settings now reads from the dictionary: notifications, sound, network, power, privacy, reset, cursor, storage, store, wallpaper, keyboard, files, about and profiles, on top of the six already done. Seven more option tables were `const` arrays at module scope and are functions of the translator now — the same freeze as before, in seven more places. That is the whole of them in Settings. Keys are not shared across pages where the Russian depends on the sentence around it. "Size" in a list and "size" in a sort order are one English word and two different Russian ones, and a shared key would have made one of them wrong. Four strings join the same-in-both list with their reasons: GitHub, the product's own name, and the legends Ctrl and Cmd — a Russian keyboard says Ctrl, so translating it would name a key nobody has.
…till 993 keys, 87 files. Fifteen components across the two applications. The interesting part is what the lint refused. Four memoised values read the translator and did not list it, so a language change would have left an old menu in place. Listing it honestly then hung five tests: `useT` built a new closure on every render, so any dependency list containing it re-ran on every render, and a memo that feeds state loops. The translator is memoised on the language now. It only changes when the language does, and it says so — which is what makes it safe to depend on. The lint asked the right question and the answer was in the hook, not in the lists. Their data modules are left for a pass of their own: the kind, size and date filters, the sort columns, the document templates and the toolbar list are exported tables read from about twenty files, and translating them means holding a key instead of a label — `t(item.labelKey)`, the idiom the scanner already anticipates. Half of that refactor would be worse than none. Also: the toolbar's "Sort" and the menu's "Sort By" are two labels again. One key made the button change its name, and a test caught it.
1104 keys, 97 files. The word and character counts these three show are the first real use of the plural machinery: Russian needs «1 слово», «2 слова», «5 слов», and a dictionary with one plural would have printed the wrong one two times in three. Reading minutes and the note count go the same way. Two more option tables — the note view modes and Writer's export formats — were `const` at module scope and are functions of the translator now. That makes eleven found this way; the pattern is worth watching for in any file still to come. Three components were given a translator they turned out not to need, and have had it taken back; several memoised values needed the translator in their dependency lists, which is safe now that it holds still. Three strings join the same-in-both list: an example URL, and the names of the HTML and Markdown formats.
1224 keys, 121 files. Twenty-four components across the four. The sentences that carry a value are written as one key with a placeholder rather than assembled from pieces: «строк: 12 × столбцов: 4» puts the numbers where Russian puts them, which a string built by concatenating "rows ×" and "columns" around them cannot do. The PDF viewer's explanation and the CSV footer are the same shape. Nothing new was learned here — the same three things as before: a translator where a component needed one, an option table that had frozen at import, and a memo that had to list the translator it reads. That is what a pattern settling looks like.
1362 keys, 139 files. Eighteen components across the four. Three things worth the words. Inserting an import at the top of a file put it above that file's `biome-ignore-all`, which stops being a file-level suppression the moment it stops being the first thing in the file. Five accessibility warnings quietly came back, in a grid whose roving tabIndex is exactly why they were suppressed in the first place. The import belongs below it, and the lint count is what noticed. `df` and `ps` print their column headers in English on every Unix, and the terminal's do the same. They are the output of a command, not the interface around it, and are left alone. A contact's address label is `home` in the card whether or not the interface is in English: it is a vCard value, saved to disk and read back, not a word on screen. It stays, with the reason beside it. What the reader sees is translated where it is drawn.
Two lines of work met in apps/web/server/page.ts and both halves are kept. The relayed page keeps the site's scripts out — a framework that throws while hydrating destroys a page that was whole in the HTML — and runs Lumen's own script under a per-response nonce, so a script the scanner missed still cannot execute. That script is the fuller one: it reports that the page arrived, and hands the address of a link or a GET form up to the browser, which goes there as though it had been typed.
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Builds Lumen OS: a desktop operating environment written in TypeScript and
React 19. The same code runs in a browser tab and natively on Windows, macOS
and Linux through Tauri 2, where a Rust kernel confines file access to a
directory you choose.
Shell — boot, a first-run assistant, a lock screen with a recovery-key
flow, the desktop with arrangeable icons, a menubar carrying the focused
app's menus, a taskbar with the Start menu, a window manager (drag, resize
from any edge, snap to edges and corners, Mission Control, Alt+Tab),
Spotlight search, Control Center, notifications, a screensaver, and a cursor
the OS draws itself with the native one hidden.
Applications — thirty-five: Files, Mail, Browser, Terminal, Text Editor,
Notes, Contacts, Reminders, Writer, Sheets, Slides, Preview, Photos, Media
Player, Paint, Calculator, Units, Colour, Character Map, Clipboard, Calendar,
Clock, Settings, Task Manager, System Information, Storage, Console,
Workbench, Archive Utility, Software Center, Minesweeper, Chess, Sudoku, 2048
and Solitaire. One more, Web App, is hidden from the launcher because it is
the host that runs installed HTML programs rather than something anybody
opens. A
.appfile is a JSON manifest that aliases a built-in app, runs ashell script, or embeds HTML that runs in a sandboxed frame with a small
window.lumenAPI.Layers —
tokens ← ui ← kernel ← apps ← shell ← apps/{web,desktop}, withplatformandvfsunder the kernel andcrates/lumen-kernelbeneath thatfor the native build.
ARCHITECTURE.mdexplains the decisions.Also — a landing site with a three.js hero, Vercel configuration for both
sites, CI for lint/typecheck/test/build and a three-platform desktop matrix
that produces the Windows installers, the macOS
.dmgand the Linux.deb,.rpmand.AppImage, and the repository conventions:CONTRIBUTING.md,CODE_OF_CONDUCT.md,SECURITY.md,AI_USAGE_POLICY.md,docs/BUILDING.md,.claude/,.agents/, and the vendored kill-ai-slop design-review skill.docs/BUILDING.mdis deliberately precise about how far "runs on Linux"goes: the AppImage is built on Ubuntu 22.04, so glibc 2.35 is the floor, and
nothing here is signed.
The browser fetches a page through
/api/pagewhen a site refuses to beframed, and serves it from Lumen's own origin with a
<base>and without thesite's scripts — those cannot work in an opaque-origin frame and, left in,
take the document apart.
apps/web/server/page.tscarries the guards thatmake a fetcher on a public host safe to run: http and https only, every
redirect hop resolved and checked against private address space, no
credentials in either direction, a size cap, a deadline, and a caller check.
How to verify
In the running OS: finish setup, then try dragging a window to a screen edge,
Ctrl+Spaceto search,Ctrl+Alt+Lto lock, and the recovery key you wereshown during setup on "Forgot password?".
Checklist
pnpm checkpasses — 31/31 turbo tasks, 6207 tests inpackages/appscargo clippy --workspace --all-targets -- -D warningspassespnpm deslopreviewed and remaining hits explained — the scan is clear;the one suppression, on Paint's 3×3 anchor grid, carries its reason
apps/web/e2e/apps.spec.tsopens Calendar and Paint at 2560×1440,800×600 and 620×560 and checks the layouts adapt and stay inside the
screen. It found one real defect, fixed in 94a163b: with a five-menu
app at 620 px the menubar clock wrapped onto two lines and painted
over the menu titles
apps/web/e2e/scale.spec.tsopens every app at 90 %, 100 % and 130 %and fails on anything that spills out of its own box. It found two
real defects, both a fixed length in a system measured in rem: an
inline field's label track and a slide thumbnail
End-to-end job as the
playwright-reportartifact, rather than pastedhere, so they are regenerated on every run instead of going stale
AI_USAGE_POLICY.md🤖 Generated with Claude Code
https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo