Skip to content

feat: Lumen OS — a desktop environment in TypeScript, for the browser and the desktop - #1

Open
lumenpearson wants to merge 176 commits into
mainfrom
claude/os-typescript-tauri-react-vnz0hz
Open

lumenpearson wants to merge 176 commits into
mainfrom
claude/os-typescript-tauri-react-vnz0hz

Conversation

@lumenpearson

@lumenpearson lumenpearson commented Sep 4, 2026 •

Copy link
Copy Markdown
Owner

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 .app file is a JSON manifest that aliases a built-in app, runs a
shell script, or embeds HTML that runs in a sandboxed frame with a small
window.lumen API.

Layers — tokens ← ui ← kernel ← apps ← shell ← apps/{web,desktop}, with
platform and vfs under the kernel and crates/lumen-kernel beneath that
for the native build. ARCHITECTURE.md explains 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 .dmg and the Linux .deb,
.rpm and .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.md is 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/page when a site refuses to be
framed, and serves it from Lumen's own origin with a <base> and without the
site's scripts — those cannot work in an opaque-origin frame and, left in,
take the document apart. apps/web/server/page.ts carries the guards that
make 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

pnpm install
pnpm dev:web        # the OS at http://localhost:5173
pnpm dev:landing    # the site at http://localhost:5175
pnpm check          # lint, typecheck, unit tests, builds
pnpm deslop         # design review
pnpm check:apps     # the landing page lists the apps the OS ships
pnpm check:portability
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
pnpm --filter @lumen/web exec playwright test   # boots the OS in Chromium

In the running OS: finish setup, then try dragging a window to a screen edge,
Ctrl+Space to search, Ctrl+Alt+L to lock, and the recovery key you were
shown during setup on "Forgot password?".

Checklist

  • pnpm check passes — 31/31 turbo tasks, 6207 tests in packages/apps
  • cargo clippy --workspace --all-targets -- -D warnings passes
  • pnpm deslop reviewed and remaining hits explained — the scan is clear;
    the one suppression, on Paint's 3×3 anchor grid, carries its reason
  • Works at a small viewport (≤ 800×600) and a large one (≥ 2560 wide) —
    apps/web/e2e/apps.spec.ts opens 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
  • Holds together at the three interface scales Settings offers —
    apps/web/e2e/scale.spec.ts opens 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
  • Keyboard reachable, focus visible
  • Screenshots attached — produced by that suite and uploaded by the
    End-to-end job as the playwright-report artifact, rather than pasted
    here, so they are regenerated on every run instead of going stale
  • AI-assisted authorship disclosed per AI_USAGE_POLICY.md

🤖 Generated with Claude Code

https://claude.ai/code/session_0156izgAkUsNj9B4w1jBcFFo

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
@vercel

vercel Bot commented Sep 4, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
lumen-os-landing Ready Ready Preview Sep 8, 2026 8:14pm UTC
lumen-os-web Ready Ready Preview Sep 8, 2026 8:14pm UTC

program-perfect and others added 4 commits September 8, 2026 00:44
…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
Comment thread apps/web/server/page.ts Fixed
Comment thread apps/web/server/page.ts Fixed
…racter sanitization'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Comment thread apps/web/server/page.ts Fixed
claude and others added 23 commits September 8, 2026 06:10
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

2 active deployments
Preview – lumen-os-landing — 7465f9c0 Deployed Sep 8, 2026 by vercel[bot]
Preview – lumen-os-web — 7465f9c0 Deployed Sep 8, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants