init: OffScale — standalone MIT Tailscale/Headscale client
Extracted from the officer-suite monorepo into its own public repo. The app is
open source and free; the rest of the suite is not, so it could not stay in a
tree that ships an account layer.
What moved:
- apps/offscale/* -> repo root (App shell, app.json, assets)
- packages/officer-net/ -> modules/officer-net/, a `file:` dependency
- packages/core VPN closure -> src/vpn/ (screen, hook, native bridge, invite)
- packages/core shared primitives -> src/ui/ (theme, cards, tabs, backdrop, splash)
- scripts/build-{ios,android}.sh -> scripts/, cut down to this one app
What deliberately did NOT move: the platform account layer. VpnScreen used to
render core's AppSettingsScreen, which drags in ServersSection -> servers.ts,
useAuth, api.ts, shared-session, install-guard, api-keys. None of that exists
here. src/settings/SettingsScreen.tsx is a fresh replacement rendering exactly
what VpnScreen passed it — the app's own sections plus About — with no Servers
tab, no Appearance control (the app is pinned to dark) and no sign-out.
The one platform reference left in the closure was a dead `import { request }
from './api'` in officer-net.ts, unused since enrollVpn() was removed. Gone. The
app now contacts nothing but the control server you point it at, its peers, and
the enrolment sidecar named inside an invite link you tapped.
Dependencies are pinned to the exact versions the monorepo has installed, not to
ranges: this repo starts from versions already proven to build, and an unpinned
install is a supply-chain decision nobody made. react-query, react-dom,
react-native-screens, expo-secure-store, track-player, expo-audio and
expo-file-system were all declared by the old app and imported by none of this
code, so they are not here.
NOTICE carries Tailscale's BSD-3 text, names the built artifacts that contain
their compiled code, and treats the trademark question separately — including
that src/ui/TailscaleMark.tsx reproduces their logo in the attribution strip.
This commit is contained in:
+192
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* The app-wide colour system. Every screen reads colours from here (via useTheme) instead of hardcoding
|
||||
* hex — so light/dark is one switch. Tokens are SEMANTIC (what a colour is for), not literal, so the two
|
||||
* palettes can diverge freely. Light is the original "duck" cream/green/teal look; dark is the "fin"
|
||||
* palette (see the old tailwind config). The immersive full-screen media player is intentionally black in
|
||||
* both themes (its own `player*` tokens); the mini dock follows the theme.
|
||||
*/
|
||||
export type ThemeMode = 'light' | 'dark';
|
||||
export type ThemePreference = 'light' | 'dark' | 'system';
|
||||
|
||||
export type Palette = {
|
||||
mode: ThemeMode;
|
||||
|
||||
// Surfaces
|
||||
bg: string; // screen background
|
||||
surface: string; // cards, inputs, list-row fills
|
||||
surfaceAlt: string; // section headers, elevated panels
|
||||
overlay: string; // modal scrim
|
||||
|
||||
// Text
|
||||
text: string; // primary
|
||||
textMuted: string; // secondary
|
||||
textFaint: string; // tertiary / placeholder
|
||||
heading: string; // section / brand headings
|
||||
|
||||
// Accent
|
||||
accent: string; // primary action / active
|
||||
accentText: string; // text/icon on an accent fill
|
||||
|
||||
// Brand (the Officer yellow — the same fill in both themes, so it carries its own ink:
|
||||
// palette.text is near-white in dark mode and unreadable on it.)
|
||||
brand: string; // brand-yellow fill for a primary CTA / selected pill
|
||||
brandText: string; // text/icon on a brand fill
|
||||
|
||||
// Lines
|
||||
border: string; // input & card borders
|
||||
hairline: string; // 1px list separators
|
||||
|
||||
/*
|
||||
Status.
|
||||
|
||||
`success` and `warning` complete the state vocabulary the web app already uses — see the platform's
|
||||
`components/Data/tone.ts`, where five tones and no others is the whole point: when a view can reach
|
||||
for any colour, green stops meaning "finished" and starts meaning "the author liked green here".
|
||||
`danger` is the third of them and predates this; `neutral` is textMuted and `info` is accent.
|
||||
|
||||
Same values as the web's tokens so a state reads identically on both, and like the web these do NOT
|
||||
change between light and dark — a finished thing is the same green wherever you look at it.
|
||||
*/
|
||||
success: string; // finished, healthy, passing
|
||||
warning: string; // in progress, needs attention, not yet failed
|
||||
danger: string; // destructive text/icon
|
||||
dangerText: string; // text on a danger fill
|
||||
dangerBg: string; // error box fill
|
||||
dangerBorder: string; // error box border
|
||||
|
||||
// Icons
|
||||
icon: string; // default icon tint
|
||||
iconFaint: string; // disabled / placeholder icon
|
||||
|
||||
// Media player (immersive; near-black in both themes)
|
||||
playerBg: string;
|
||||
playerText: string;
|
||||
playerMuted: string;
|
||||
playerFaint: string;
|
||||
playerAccent: string;
|
||||
playerHeart: string; // active heart
|
||||
dockBg: string; // mini-player dock (follows theme)
|
||||
};
|
||||
|
||||
export const lightPalette: Palette = {
|
||||
mode: 'light',
|
||||
bg: '#ede7de',
|
||||
surface: 'rgba(255,255,255,0.6)',
|
||||
surfaceAlt: '#d8d3c9',
|
||||
overlay: 'rgba(0,0,0,0.4)',
|
||||
text: '#14532D',
|
||||
textMuted: 'rgba(20,83,45,0.6)',
|
||||
textFaint: 'rgba(20,83,45,0.4)',
|
||||
heading: '#166534',
|
||||
accent: '#0E7490',
|
||||
accentText: '#ffffff',
|
||||
brand: '#F4C430',
|
||||
brandText: '#0E7490',
|
||||
border: 'rgba(20,83,45,0.2)',
|
||||
hairline: 'rgba(20,83,45,0.08)',
|
||||
// hsl(142.1 76.2% 36.3%) and hsl(43 96% 56%) — the web's --success and --warning, converted.
|
||||
success: '#16A34A',
|
||||
warning: '#FABD23',
|
||||
danger: '#B91C1C',
|
||||
dangerText: '#ffffff',
|
||||
dangerBg: '#fef2f2',
|
||||
dangerBorder: '#fca5a5',
|
||||
icon: '#0E7490',
|
||||
iconFaint: 'rgba(20,83,45,0.35)',
|
||||
playerBg: '#000000',
|
||||
playerText: '#ffffff',
|
||||
playerMuted: 'rgba(255,255,255,0.6)',
|
||||
playerFaint: 'rgba(255,255,255,0.3)',
|
||||
playerAccent: '#0891B2',
|
||||
playerHeart: '#EF4444',
|
||||
dockBg: 'rgba(237,231,222,0.98)',
|
||||
};
|
||||
|
||||
export const darkPalette: Palette = {
|
||||
mode: 'dark',
|
||||
bg: '#101418',
|
||||
/*
|
||||
Translucent, mirroring light mode.
|
||||
|
||||
`#1b2027` at 60%. Light has always been rgba(255,255,255,0.6) — cards there were built to let the
|
||||
wallpaper through — and dark was opaque, so the same card read as a panel on one theme and a pane
|
||||
on the other. Over the dusk photograph (0.12 mean luminance) a dark card at 60% stays dark, so
|
||||
near-white body text keeps its contrast.
|
||||
|
||||
`surfaceAlt` deliberately stays opaque: it is the "elevated panel" token, and things that float
|
||||
ABOVE content — dropdowns, tab bars — must not have that content reading through them.
|
||||
*/
|
||||
surface: 'rgba(27,32,39,0.6)',
|
||||
surfaceAlt: '#242b34',
|
||||
overlay: 'rgba(0,0,0,0.6)',
|
||||
text: '#e8eaed',
|
||||
/*
|
||||
Secondary text, not faint text.
|
||||
|
||||
Was #9aa4b2 — a mid grey that held up against an opaque #1b2027 card and stopped holding up the
|
||||
moment the card became translucent over a photograph. Most of the writing on these screens is
|
||||
`textMuted`: subtitles, peer rows, every explanatory line under a heading. It has to be readable
|
||||
first and quiet second, and it was the other way round.
|
||||
|
||||
Now the same near-white as `text`, at 75%, so the two read as a hierarchy rather than as one colour
|
||||
and both sit on one hue instead of drifting blue-grey apart.
|
||||
|
||||
It carries less than it used to. Body copy — anything the reader is meant to actually read — was
|
||||
moved to `text`, because the honest answer to "why is this muted if it matters" is that it should
|
||||
not be. What is left here is labels, timestamps and row metadata, which is what secondary means.
|
||||
*/
|
||||
textMuted: 'rgba(232,234,237,0.75)',
|
||||
textFaint: 'rgba(232,234,237,0.55)',
|
||||
heading: '#e8eaed',
|
||||
/*
|
||||
Dark mode is yellow, not blue.
|
||||
|
||||
The accent was #00a4dc — Jellyfin's blue, inherited when the player was the first thing built here
|
||||
and never revisited. It is the one colour on these screens that belongs to somebody else's product,
|
||||
sitting beside a suite whose entire identity is a yellow duck.
|
||||
|
||||
So `accent` is now the duck yellow, and everything keyed off it moves together: buttons, icons,
|
||||
selected states, bullets, the emphasised word in the disclosure.
|
||||
|
||||
Ink on yellow is `duck-forest` — #166534, taken from the platform's own tokens rather than invented
|
||||
here, where it is already paired with duck-yellow. Roughly 4.4:1 against #F4C430: under the 4.5
|
||||
needed for body text, comfortably over the 3.0 for large bold type, which is all this is used for.
|
||||
|
||||
Note WHICH forest. The platform lightens it to #4ade80 for dark mode, because there it sits on a
|
||||
dark background. Ours sits on YELLOW — a light surface even in dark mode — so it wants the light
|
||||
theme's dark forest. Taking the dark-mode value would be reading the token by name rather than by
|
||||
the job it does.
|
||||
|
||||
`accentText` and `brandText` share it, so a yellow button and a yellow tab pill carry the same ink.
|
||||
They did not before.
|
||||
|
||||
LIGHT MODE IS UNCHANGED, and that is now a decision rather than a deferral (2026-08-07): the
|
||||
redesign is dark-only, across every app. Light keeps the teal accent and its own surfaces. Yellow on
|
||||
cream never has to be solved, because light is not being restyled — and the sign-in screen, which is
|
||||
deliberately always-light, is unaffected by any of this.
|
||||
*/
|
||||
accent: '#F4C430',
|
||||
accentText: '#0891B2',
|
||||
brand: '#F4C430',
|
||||
brandText: '#0891B2',
|
||||
border: 'rgba(255,255,255,0.14)',
|
||||
hairline: 'rgba(255,255,255,0.08)',
|
||||
// Unchanged from light, exactly as the web keeps --success and --warning identical across themes.
|
||||
success: '#16A34A',
|
||||
warning: '#FABD23',
|
||||
danger: '#f87171',
|
||||
dangerText: '#ffffff',
|
||||
dangerBg: 'rgba(248,113,113,0.12)',
|
||||
dangerBorder: 'rgba(248,113,113,0.4)',
|
||||
icon: '#F4C430',
|
||||
iconFaint: 'rgba(232,234,237,0.35)',
|
||||
playerBg: '#000000',
|
||||
playerText: '#ffffff',
|
||||
playerMuted: 'rgba(255,255,255,0.6)',
|
||||
playerFaint: 'rgba(255,255,255,0.3)',
|
||||
playerAccent: '#00a4dc',
|
||||
playerHeart: '#EF4444',
|
||||
dockBg: '#1b2027',
|
||||
};
|
||||
|
||||
export const paletteFor = (mode: ThemeMode): Palette => (mode === 'dark' ? darkPalette : lightPalette);
|
||||
Reference in New Issue
Block a user