From e1bd19dbbaedecf0a61c3902ba6a1681a94320d8 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:59:10 +0000 Subject: [PATCH 01/22] feat(site-controls): a section registry, so other features add rows to the site controls panel Modules named *.site-controls.tsx that export a SiteControlsSection are collected at build time and rendered between the panel's built-in sections by their order (Permissions 20, Devices 30, Picture in Picture 40, Zoom 50). Tweaks (BO-14) and the extensions lane use it (D37). Documented at its definition. Co-Authored-By: Claude Opus 5.5 --- .../src/overlay/site/SiteControls.tsx | 16 +++- src/renderer/src/overlay/site/registry.ts | 94 +++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/overlay/site/registry.ts diff --git a/src/renderer/src/overlay/site/SiteControls.tsx b/src/renderer/src/overlay/site/SiteControls.tsx index a44e8d8e..b786ca67 100644 --- a/src/renderer/src/overlay/site/SiteControls.tsx +++ b/src/renderer/src/overlay/site/SiteControls.tsx @@ -25,6 +25,7 @@ import type { OverlayState } from '../../../../shared/state' import { invoke } from '../../api' import { DEVICE_ICON, PERMISSION_ICON } from '../../shell/chrome/PermissionPrompt' import { useOrbit } from '../../store' +import { BUILT_IN_ORDER, SITE_CONTROLS_SECTIONS } from './registry' import './site.css' type PanelOverlay = Extract @@ -63,7 +64,7 @@ function connectionLine(connection: Connection): { icon: ReactNode; text: string * permissions the site was given, refused or asked for with a switch each (pop-ups too), devices * it may use, zoom with a reset, and clearing its cookies and data. Changes apply at once; when the * page only sees them after a reload, a Reload button says so. Esc or a click outside closes it; - * arrows move between its controls. + * arrows move between its controls. Other features add their sections through `./registry`. */ export function SiteControls({ overlay, dark = false }: { overlay: PanelOverlay; dark?: boolean }) { const params = overlay.params ?? {} @@ -156,6 +157,14 @@ export function SiteControls({ overlay, dark = false }: { overlay: PanelOverlay; refresh() } + // Other features' sections (./registry), between the built-in ones by their `order`. + const registered = (from: number, to: number) => + info + ? SITE_CONTROLS_SECTIONS.filter((s) => s.order >= from && s.order < to).map((s) => ( + + )) + : null + const conn = info ? connectionLine(info.connection) : null return (
)} + {registered(Number.NEGATIVE_INFINITY, BUILT_IN_ORDER.permissions)} {info.web && (

Permissions

@@ -227,6 +237,7 @@ export function SiteControls({ overlay, dark = false }: { overlay: PanelOverlay;
)} + {registered(BUILT_IN_ORDER.permissions, BUILT_IN_ORDER.devices)} {info.devices.length > 0 && (

Devices

@@ -258,7 +269,9 @@ export function SiteControls({ overlay, dark = false }: { overlay: PanelOverlay;
)} + {registered(BUILT_IN_ORDER.devices, BUILT_IN_ORDER.pip)} {info.web && } + {registered(BUILT_IN_ORDER.pip, BUILT_IN_ORDER.zoom)}
@@ -294,6 +307,7 @@ export function SiteControls({ overlay, dark = false }: { overlay: PanelOverlay; )}
+ {registered(BUILT_IN_ORDER.zoom, Number.POSITIVE_INFINITY)} {info.web && (
diff --git a/src/renderer/src/overlay/site/registry.ts b/src/renderer/src/overlay/site/registry.ts new file mode 100644 index 00000000..e6f0326a --- /dev/null +++ b/src/renderer/src/overlay/site/registry.ts @@ -0,0 +1,94 @@ +import type { ComponentType } from 'react' +import type { SiteControlsInfo } from '../../../../shared/site' +import type { TabId } from '../../../../shared/state' + +/** What every registered section's component gets. */ +export interface SiteSectionProps { + /** The tab the panel is for. */ + tabId: TabId + /** What main said about its page (`site.info`): URL, host, connection, `web`, `private`… */ + info: SiteControlsInfo + /** Asks main for `info` again, e.g. after a change that the built-in rows show. */ + refresh(): void + /** Closes the panel, e.g. before opening something of the section's own in its place. */ + close(): Promise +} + +/** + * The site controls panel's section registry (NV-16): other features' rows in the panel, such as + * a site's Tweaks (BO-14) or the extensions that may read it (extensions lane). + * + * A section is a block of rows between the panel's built-in ones. Sections are found by file name: + * every module under `src/renderer/src/` whose name ends in `.site-controls.tsx` and exports + * `section: SiteControlsSection` is in the panel, so adding one edits no panel file: + * + * ```tsx + * // src/renderer/src/tweaks/tweaks.site-controls.tsx + * import type { SiteControlsSection, SiteSectionProps } from '../overlay/site/registry' + * + * function TweakRows({ tabId, info }: SiteSectionProps) { + * if (!info.web) return null + * return ( + *
+ *

Tweaks

+ *
    …
+ *
+ * ) + * } + * + * export const section: SiteControlsSection = { id: 'tweaks', order: 45, component: TweakRows } + * ``` + * + * - `id`: stable and unique (the first module of a duplicate id wins); the section's React key. + * - `order`: its place among the built-in sections, low first. The panel's own are Permissions 20, + * Devices 30, Picture in Picture 40 and Zoom 50, between the header (site, connection, Reload) + * and the footer (reset permissions, clear site data). Pick a number between two to slot in. + * - `component`: rendered with `SiteSectionProps` once `site.info` has answered, on every kind of + * page; it returns null where it has nothing to say (`info.web` is false on internal, file and + * blank pages). Build it from the panel's classes (`site.css`): `site-panel__section`, + * `site-panel__title`, `site-panel__rows`, `site-panel__row`, `site-panel__name` with a + * `site-panel__state` line, `site-switch` (a `role="switch"` button) and + * `site-panel__icon-button`. Use ` + + +
+ ) +} diff --git a/src/renderer/src/tweaks/TweakEditorApp.tsx b/src/renderer/src/tweaks/TweakEditorApp.tsx new file mode 100644 index 00000000..a27ceeda --- /dev/null +++ b/src/renderer/src/tweaks/TweakEditorApp.tsx @@ -0,0 +1,203 @@ +import { Paintbrush, X } from 'lucide-react' +import { type CSSProperties, type ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { TWEAK_EDITOR, type TweakEditorState } from '../../../shared/commands/tweaks' +import type { Tweak } from '../../../shared/state' +import { invoke } from '../api' +import { useOrbit, useWindow } from '../store' +import { paletteVars, usePalette } from '../theme' +import { SizeControl } from './SizeControl' +import './tweaks.css' + +/** + * The Tweak editor panel's view (BO-02): main keeps it over the top-right corner of the page the + * Tweak edits and sizes it to the panel, so the page around it stays live. Main says what to show + * (`tweakEditor`); the Tweak itself comes from the state. + */ +export function TweakEditorApp() { + const win = useWindow() + const palette = usePalette(win?.spaceId) + const [state, setState] = useState(null) + useEffect(() => { + let alive = true + let pushed = false + const off = window.orbit.on('tweakEditor', (payload) => { + pushed = true + setState(payload as TweakEditorState) + }) + // Main may have opened the panel before this page could hear it. + void invoke('tweaks.editorState', {}).then((s) => { + if (alive && !pushed) setState(s) + }) + return () => { + alive = false + off() + } + }, []) + const tweak = useOrbit((s) => (state?.tweakId ? s.tweaks[state.tweakId] : undefined)) + const introSeen = useOrbit((s) => s.settings.tweakIntroSeen) + return ( +
+ {state?.open && tweak && ( + + )} +
+ ) +} + +function TweakPanel({ state, tweak, intro }: { state: TweakEditorState; tweak: Tweak; intro: boolean }) { + const panel = useRef(null) + const head = useRef(null) + const content = useRef(null) + const foot = useRef(null) + const wide = false + + // Main sizes the view to the panel: its natural height, whatever room the view has now. + useLayoutEffect(() => { + const parts = [head.current, content.current, foot.current] + const report = () => { + const height = parts.reduce((sum, el) => sum + (el?.offsetHeight ?? 0), 0) + 2 + void invoke('tweaks.editorLayout', { width: wide ? TWEAK_EDITOR.wideWidth : TWEAK_EDITOR.width, height }) + } + report() + const observer = new ResizeObserver(report) + for (const el of parts) if (el) observer.observe(el) + return () => observer.disconnect() + }, [wide]) + + // Each opening starts at the top, with the keyboard in the panel: Esc closes it (BO-02, KS-19). + useEffect(() => { + panel.current?.focus({ preventScroll: true }) + }, []) + + const close = () => void invoke('tweaks.closeEditor', {}) + const drag = useDrag() + + return ( +
{ + if (e.key !== 'Escape' || e.defaultPrevented || e.nativeEvent.isComposing) return + e.preventDefault() + close() + }} + > +
+ + + +
+ + {tweak.name} + + {tweak.name !== state.host && {state.host}} +
+ +
+
+
+ {intro ? : } +
+
+
+ {!intro && ( + + )} +
+
+ ) +} + +/** The first-use intro (BO-01): read once, then never again. */ +function Intro({ host }: { host: string }) { + return ( +
+
+ +
+

Tweak any website

+

+ Change how {host} looks: its colours, fonts and size, or hide what you don't need. Every + change shows at once and is saved as you go. +

+

+ Tweaks stay on this device and come back each time you visit. Turn them off any time from the site controls. +

+ +
+ ) +} + +function Controls({ tweak }: { tweak: Tweak }) { + return ( + <> + + + + + ) +} + +export function Row({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ) +} + +/** + * Dragging the panel by its title (BO-02): main moves the view, so positions go as screen points + * (the view moves under the pointer; the screen doesn't). + */ +function useDrag() { + const dragging = useRef(false) + const send = (phase: 'start' | 'move' | 'end', e: React.PointerEvent) => + window.orbit.send('tweaks.editorDrag', { phase, x: e.screenX, y: e.screenY }) + const end = (e: React.PointerEvent) => { + if (!dragging.current) return + dragging.current = false + send('end', e) + } + return { + onPointerDown: (e: React.PointerEvent) => { + if (e.button !== 0 || (e.target as HTMLElement).closest('button, input, select, textarea')) return + dragging.current = true + e.currentTarget.setPointerCapture(e.pointerId) + send('start', e) + }, + onPointerMove: (e: React.PointerEvent) => { + if (dragging.current) send('move', e) + }, + onPointerUp: end, + onPointerCancel: end, + } +} diff --git a/src/renderer/src/tweaks/edit.ts b/src/renderer/src/tweaks/edit.ts new file mode 100644 index 00000000..da81e30a --- /dev/null +++ b/src/renderer/src/tweaks/edit.ts @@ -0,0 +1,7 @@ +import type { TweakPatch } from '../../../shared/tweaks' +import { invoke } from '../api' + +/** Sends an edit of a Tweak to main, which saves it and applies it to the page at once (BO-02). */ +export function updateTweak(id: string, patch: TweakPatch): void { + void invoke('tweaks.update', { id, patch }) +} diff --git a/src/renderer/src/tweaks/tweaks.css b/src/renderer/src/tweaks/tweaks.css new file mode 100644 index 00000000..de5a2f20 --- /dev/null +++ b/src/renderer/src/tweaks/tweaks.css @@ -0,0 +1,313 @@ +/* + * The Tweak editor panel's own view (view=tweak, BO-02): transparent, over the top-right corner of + * the page it edits, as big as the panel plus an 8 px margin for its shadow (TWEAK_EDITOR in + * shared/commands/tweaks.ts). The panel is in the Space's colours, like the site controls panel. + */ +html[data-view="tweak"] body { + background: transparent; + overflow: hidden; +} + +.tweak-view { + position: fixed; + inset: 0; + padding: 8px; + display: flex; + justify-content: flex-start; + align-items: flex-start; + color: var(--text, #1c1c1f); + font-size: 13px; + line-height: 1.35; +} + +.tweak-panel { + --tweak-line: color-mix(in srgb, var(--text, #1c1c1f) 9%, transparent); + --tweak-fill: color-mix(in srgb, var(--text, #1c1c1f) 6%, transparent); + --tweak-fill-hover: color-mix(in srgb, var(--text, #1c1c1f) 11%, transparent); + --tweak-muted: var(--text-muted, rgba(28, 28, 31, 0.56)); + box-sizing: border-box; + display: flex; + flex-direction: column; + max-width: 100%; + max-height: calc(100vh - 16px); + border-radius: 14px; + background: var(--surface, #fff); + box-shadow: + 0 0 0 0.5px rgba(0, 0, 0, 0.16), + 0 4px 7px rgba(0, 0, 0, 0.12), + 0 1px 2px rgba(0, 0, 0, 0.08); + outline: none; + user-select: none; + overflow: hidden; + animation: tweak-panel-in 160ms var(--ease-out, ease-out); + transform-origin: 100% 0; +} + +.tweak-view.is-dark .tweak-panel { + box-shadow: + 0 0 0 0.5px rgba(255, 255, 255, 0.14), + 0 4px 7px rgba(0, 0, 0, 0.4), + 0 1px 2px rgba(0, 0, 0, 0.3); +} + +@keyframes tweak-panel-in { + from { + opacity: 0; + transform: scale(0.97) translateY(-4px); + } +} + +.tweak-panel__head { + flex: none; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 6px 8px 10px; + border-bottom: 1px solid var(--tweak-line); + cursor: grab; + touch-action: none; +} + +.tweak-panel__head:active { + cursor: grabbing; +} + +.tweak-panel__badge { + flex: none; + display: grid; + place-items: center; + width: 24px; + height: 24px; + border-radius: 7px; + background: color-mix(in srgb, var(--accent, #5865d8) 16%, transparent); + color: var(--accent, #5865d8); +} + +.tweak-panel__title { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.tweak-panel__name { + overflow: hidden; + font-size: 13px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tweak-panel__host { + overflow: hidden; + color: var(--tweak-muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tweak-panel__icon { + flex: none; + display: grid; + place-items: center; + width: 26px; + height: 26px; + padding: 0; + border: none; + border-radius: 7px; + background: none; + color: var(--tweak-muted); + cursor: default; +} + +.tweak-panel__icon:hover { + background: var(--tweak-fill); + color: inherit; +} + +.tweak-panel__body { + flex: 1 1 auto; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; +} + +.tweak-panel__content { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px; +} + +.tweak-panel__foot { + flex: none; + display: flex; + justify-content: flex-end; + gap: 6px; + padding: 0 8px 8px; +} + +.tweak-panel__foot:empty { + padding: 0; +} + +.tweak-panel button:focus-visible, +.tweak-panel input:focus-visible { + outline: 2px solid var(--accent, #5865d8); + outline-offset: 1px; +} + +/* ---------- Rows and buttons ---------- */ + +.tweak-row { + display: flex; + align-items: center; + gap: 10px; + min-height: 34px; + padding: 0 4px 0 6px; +} + +.tweak-row__label { + flex: 1; + min-width: 0; + color: var(--tweak-muted); + font-size: 12px; + font-weight: 600; +} + +.tweak-row__control { + flex: none; + display: flex; + align-items: center; + gap: 4px; +} + +.tweak-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 28px; + padding: 4px 14px; + border: none; + border-radius: 8px; + background: var(--tweak-fill); + color: inherit; + font: inherit; + font-size: 12px; + font-weight: 650; + white-space: nowrap; + cursor: default; +} + +.tweak-button:hover:not(:disabled) { + background: var(--tweak-fill-hover); +} + +.tweak-button.is-primary { + background: var(--accent, #5865d8); + color: #fff; +} + +.tweak-button.is-primary:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent, #5865d8) 88%, #000); +} + +.tweak-button.is-wide { + width: 100%; +} + +.tweak-button:disabled { + opacity: 0.4; +} + +/* − value + */ +.tweak-stepper { + display: flex; + align-items: center; + gap: 2px; + padding: 2px; + border-radius: 9px; + background: var(--tweak-fill); +} + +.tweak-stepper__button, +.tweak-stepper__value { + display: grid; + place-items: center; + height: 26px; + padding: 0; + border: none; + border-radius: 7px; + background: none; + color: inherit; + font: inherit; + cursor: default; +} + +.tweak-stepper__button { + width: 28px; +} + +.tweak-stepper__value { + min-width: 52px; + font-size: 12px; + font-weight: 650; + font-variant-numeric: tabular-nums; +} + +.tweak-stepper__button:hover:not(:disabled), +.tweak-stepper__value:hover { + background: var(--surface, #fff); +} + +.tweak-stepper__button:disabled { + opacity: 0.35; +} + +/* ---------- First-use intro (BO-01) ---------- */ + +.tweak-intro { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + padding: 6px 4px 2px; +} + +.tweak-intro__art { + display: grid; + place-items: center; + width: 40px; + height: 40px; + border-radius: 12px; + background: conic-gradient(from 200deg, #ff7a7a, #ffd36e, #7be495, #6ec8ff, #b18cff, #ff7a7a); + color: #fff; +} + +.tweak-intro__title { + margin: 4px 0 0; + font-size: 15px; + font-weight: 700; +} + +.tweak-intro__text { + margin: 0; + color: var(--tweak-muted); + font-size: 12.5px; + overflow-wrap: anywhere; +} + +.tweak-intro__text strong { + color: var(--text, #1c1c1f); + font-weight: 650; +} + +.tweak-intro .tweak-button { + margin-top: 6px; +} + +.reduce-motion .tweak-panel { + animation: none; +} diff --git a/src/shared/actions.ts b/src/shared/actions.ts index b724533e..8201f7aa 100644 --- a/src/shared/actions.ts +++ b/src/shared/actions.ts @@ -15,6 +15,7 @@ import { IMPORT_ACTIONS } from './commands/import' import type { ShortcutPriority } from './commands/keys' import { SETTINGS_ACTIONS, SETTINGS_LANE_ACTIONS } from './commands/settings' +import { TWEAK_ACTIONS } from './commands/tweaks' import { acceleratorFromInput, type CustomBindings, @@ -557,6 +558,8 @@ export const ACTIONS: ActionDef[] = [ ...SETTINGS_ACTIONS, ...SETTINGS_LANE_ACTIONS, ...IMPORT_ACTIONS, + // Tweaks (BO-21): none bound by default + ...TWEAK_ACTIONS, ] export const ACTIONS_BY_ID: Record = Object.fromEntries(ACTIONS.map((a) => [a.id, a])) diff --git a/src/shared/commands/tweaks.ts b/src/shared/commands/tweaks.ts index 3bd683d0..f6c9e99b 100644 --- a/src/shared/commands/tweaks.ts +++ b/src/shared/commands/tweaks.ts @@ -1,3 +1,114 @@ -/** Owned by the tweaks lane. Add this area's commands here. */ -// biome-ignore lint/complexity/noBannedTypes: filled in by the tweaks lane -export type TweakCommands = {} +import type { ActionDef } from '../actions' +import type { TabId } from '../state' +import type { TweakPatch } from '../tweaks' + +/** + * Tweaks (BO-*): per-site customisations. Owned by the tweaks lane. Pure logic is in + * src/shared/tweaks.ts, the main side in src/main/tweaks/, the editor in src/renderer/src/tweaks/. + */ + +/** What the editor panel shows (BO-02): main says, the panel's page draws it. */ +export interface TweakEditorState { + /** The panel is out, for this Tweak over this tab's page. */ + open: boolean + tabId: TabId | null + tweakId: string | null + /** The page's host, which the Tweak applies to. */ + host: string | null + /** Bumped by every opening, so the panel starts fresh (focus, scroll) each time. */ + nonce: number + /** The page is in a private window (BO-17: JavaScript Tweaks run there only if allowed). */ + private: boolean +} + +/** + * The editor panel's view (BO-02): the panel at the top-right corner of the page it edits, plus a + * margin around it for its shadow. The panel's page says how big the panel is (`tweaks.editorLayout`). + */ +export const TWEAK_EDITOR = { + /** Room around the panel for its shadow. */ + margin: 8, + /** The panel's distance from the page's top and right edges. */ + inset: 12, + /** The panel's width; wider while its code area is open (BO-11). */ + width: 296, + wideWidth: 460, + /** The smallest page the panel shows over. */ + minPage: 160, +} as const + +export interface TweakCommands { + /** + * A new Tweak for the site in a tab (BO-01), opened in the editor: from the + menu, the command + * bar, the site controls panel, the address field and the tab's menu. Without `tabId`, the + * calling window's shown tab. Its id, or null where Tweaks can't go (not a website, or Tweaks off), + * with a note in the window saying why. + */ + 'tweaks.create': { args: { tabId?: TabId }; result: string | null } + /** + * Opens the editor for a Tweak over a tab's page (BO-02's reopen, the › in the site controls panel). + * Without `tabId`, the calling window's shown tab. False when the Tweak doesn't apply there. + */ + 'tweaks.edit': { args: { id: string; tabId?: TabId }; result: boolean } + /** Changes a Tweak (the editor's controls, BO-02 to BO-12): checked, saved as it is made. */ + 'tweaks.update': { args: { id: string; patch: TweakPatch }; result: boolean } + /** Turns a Tweak on or off (BO-14, BO-15). */ + 'tweaks.setEnabled': { args: { id: string; enabled: boolean }; result: void } + /** Deletes a Tweak (BO-15): its styles leave open pages at once. */ + 'tweaks.delete': { args: { id: string }; result: void } + /** The editor panel's state, for its page as it starts (main pushes changes on `tweakEditor`). */ + 'tweaks.editorState': { args: Record; result: TweakEditorState } + /** Closes the calling window's editor panel (✕, Done, Esc): every edit is already saved (BO-02). */ + 'tweaks.closeEditor': { args: Record; result: void } + /** The editor panel's size, as its page lays it out: main sizes its view to fit. */ + 'tweaks.editorLayout': { args: { width: number; height: number }; result: void } + /** The editor's one-screen intro was read (BO-01): it never shows again. */ + 'tweaks.dismissIntro': { args: Record; result: void } +} + +/** Fire-and-forget events from the editor panel's view. */ +export interface TweakEvents { + /** Dragging the panel by its title (BO-02), in screen DIPs. */ + 'tweaks.editorDrag': { phase: 'start' | 'move' | 'end'; x: number; y: number } +} + +/** + * Tweak actions (BO-21): in the command bar, assignable in Settings → Shortcuts, none bound by + * default (BO-01). They act on the window's shown page. + */ +export const TWEAK_ACTIONS: ActionDef[] = [ + { + id: 'tweak.new', + title: 'New Tweak…', + category: 'Page', + keywords: [ + 'tweak this site', + 'customize', + 'customize site', + 'customize this site', + 'customization', + 'new customization', + 'site customization', + 'site style', + 'restyle', + ], + }, + { + id: 'tweak.edit', + title: 'Edit Tweak', + category: 'Page', + keywords: ['customize', 'customization', 'edit customization', 'site style', 'tweaks'], + }, + { + id: 'tweak.siteOff', + title: 'Turn Off Tweaks for This Site', + category: 'Page', + keywords: ['customize', 'customization', 'disable customizations', 'site style', 'original look'], + }, + { + id: 'tweak.siteOn', + title: 'Turn On Tweaks for This Site', + category: 'Page', + keywords: ['customize', 'customization', 'enable customizations', 'site style'], + }, +] diff --git a/src/shared/defaults.ts b/src/shared/defaults.ts index e770bfb4..846d5aa5 100644 --- a/src/shared/defaults.ts +++ b/src/shared/defaults.ts @@ -52,6 +52,8 @@ export const DEFAULT_SETTINGS: Settings = { archiveKeepDays: 30, recentFiles: { downloads: true, screenshots: false }, libraryScreenshots: false, + tweaksEnabled: true, + tweakIntroSeen: false, } /** Favorites per profile (FV-02). */ diff --git a/src/shared/rpc.ts b/src/shared/rpc.ts index 1945f41f..5e92be0b 100644 --- a/src/shared/rpc.ts +++ b/src/shared/rpc.ts @@ -24,7 +24,7 @@ import type { SettingsCommands } from './commands/settings' import type { SpaceCommands } from './commands/spaces' import type { SplitCommands } from './commands/split' import type { TabCommands } from './commands/tabs' -import type { TweakCommands } from './commands/tweaks' +import type { TweakCommands, TweakEvents } from './commands/tweaks' export interface OrbitCommands extends CoreCommands, @@ -45,7 +45,7 @@ export interface OrbitCommands MediaCommands, KeyCommands {} -export interface OrbitEvents extends CoreEvents, PeekEvents, MediaEvents {} +export interface OrbitEvents extends CoreEvents, PeekEvents, MediaEvents, TweakEvents {} export type CommandName = keyof OrbitCommands export type CommandArgs = OrbitCommands[K] extends { args: infer A } ? A : never @@ -89,6 +89,8 @@ export type RendererView = | 'import' /** Downloads lane: an internal page, `orbit:///` in a tab (DT-13). */ | 'internal' + /** Tweaks lane: the Tweak editor panel over the page (BO-02). */ + | 'tweak' /** The API the preload exposes on `window.orbit` to Orbit's own UI renderers. */ export interface OrbitBridge { diff --git a/src/shared/site.ts b/src/shared/site.ts index 1f179314..7eb72e4d 100644 --- a/src/shared/site.ts +++ b/src/shared/site.ts @@ -204,8 +204,20 @@ export const PAGE_CHANNEL = { * only for that tab's sharing indicators. */ screenShare: 'orbit-page:screen-share', + /** + * sendSync(): the stylesheets of the Tweaks for this page (BO-18; D40), as CSS texts, inserted + * before first paint. Main answers only a main frame showing a website, from the frame's own URL + * (src/main/tweaks/pages.ts); the message carries nothing. + */ + tweakSheets: 'orbit-page:tweak-sheets', } as const +/** + * The media list of the Tweak stylesheets the page preload inserts (BO-18): all media, plus a media + * type nothing else uses, by which main finds those sheets to take them out (src/main/tweaks/pages.ts). + */ +export const TWEAK_SHEET_MEDIA = 'all, orbit-tweaks' + /** A tab comes forward for its notifications' clicks at most this often (a flood can't stall the shell). */ export const NOTIFICATION_CLICK_GAP_MS = 500 diff --git a/src/shared/state.ts b/src/shared/state.ts index 04b14a15..7e15705b 100644 --- a/src/shared/state.ts +++ b/src/shared/state.ts @@ -388,6 +388,10 @@ export interface Settings { * become short cross-fades. Off: Orbit follows the OS setting. */ reduceMotion: boolean + /** Apply Tweaks on websites (BO-16): off, none applies anywhere; each Tweak keeps its own switch. */ + tweaksEnabled: boolean + /** The Tweak editor's one-screen intro was dismissed (BO-01): it never shows again. */ + tweakIntroSeen: boolean } /** Secure DNS choices (ST-12): providers are src/main/settings/dns.ts's DOH_SERVERS. */ @@ -463,15 +467,60 @@ export interface Download { danger?: 'file-type' } -/** Per-site customisation ("Tweak"): CSS/JS applied to matching hosts. */ +/** + * Per-site customisation ("Tweak", BO-*): no-code style, CSS and JavaScript applied to matching + * hosts. Pure logic (matching, CSS) is in src/shared/tweaks.ts. + */ export interface Tweak { id: string + /** The host it was made on: a lowercase hostname without the port (BO-17). */ host: string name: string enabled: boolean css: string js: string createdAt: number + /** The last edit, for "most recent" orders. */ + updatedAt: number + /** Also applies on the host's subdomains (BO-17; off by default). */ + subdomains: boolean + /** Its JavaScript may run on this device (BO-11, BO-18): an imported Tweak's is off until allowed. */ + jsAllowed: boolean + /** Its JavaScript runs in private windows too (BO-17; off by default). Its CSS always does. */ + jsInPrivate: boolean + /** The no-code controls (BO-03 to BO-10). */ + style: TweakStyle +} + +/** A Tweak's no-code controls; every field at its default changes nothing. */ +export interface TweakStyle { + /** Colour wheel (BO-03): main hue in degrees; how strong the tint is, 0 (none) to 1. */ + hue: number + intensity: number + /** The wheel's rim handle (BO-03): the accent hue, in degrees. */ + accent: number + /** Invert lightness (BO-04). */ + invert: boolean + /** Advanced colour sliders (BO-05), in percent: 100 changes nothing. */ + contrast: number + brightness: number + saturation: number + /** A font preset's id (BO-07); '' keeps the site's fonts. */ + font: string + /** Page size in percent (BO-08), 90 to 150; it multiplies with page zoom (NV-06). */ + size: number + /** Text case (BO-09). */ + textCase: 'none' | 'lower' | 'upper' | 'capitalize' + /** Hidden elements (BO-10), oldest first. */ + hidden: TweakHidden[] +} + +/** One Hide-element action (BO-10): plain CSS selector, hidden with `display: none`. */ +export interface TweakHidden { + id: string + selector: string + /** What was hidden, in words, for its restore marker. */ + label: string } export interface OrbitState { diff --git a/src/shared/tweaks.ts b/src/shared/tweaks.ts new file mode 100644 index 00000000..a0cda0c1 --- /dev/null +++ b/src/shared/tweaks.ts @@ -0,0 +1,1057 @@ +/** + * Tweaks (BO-*): per-site customisations, as pure logic shared by main and the renderers. + * + * - The model: a Tweak's defaults, normalisation of what was saved, and checked edits. + * - Matching (BO-17): which Tweaks apply to a page's host. An index by host makes it a few map + * lookups (the host, then each parent domain for "also apply to subdomains"), never a scan. + * - The stylesheets (BO-18): what a page gets, as several sheets (`tweakSheets`): the colours (the + * wheel, invert and sliders: a filter on the root, and its inverse on images and video), the type + * (fonts, size, case), and each Tweak's hidden elements and own CSS. All sit in the cascade layer + * `orbit-tweaks`, every declaration `!important`: a layered important declaration beats the + * page's own important ones, wherever the sheet sits in the cascade. + * - The Space colours (BO-19): `var(--orbit-theme-…)` in a Tweak's CSS is replaced by the colour + * itself, so the variables exist only inside Tweaks and never on the page. + * - JavaScript (BO-11): the code main runs in the page, with errors going to the page's console. + */ +import { TWEAK_SHEET_MEDIA } from './site' +import { hexToHsl } from './theme' +import type { Settings, Tweak, TweakHidden, TweakStyle } from './state' + +// ---- Model ---------------------------------------------------------------- + +/** The cascade layer every Tweak rule is in; also the media type that marks the preload's sheets. */ +export const TWEAK_LAYER = 'orbit-tweaks' +export { TWEAK_SHEET_MEDIA } + +/** Page size steps (BO-08), in percent. The −/+ buttons move between them. */ +export const TWEAK_SIZES = [90, 100, 110, 125, 150] as const +export const TWEAK_SIZE_MIN = 90 +export const TWEAK_SIZE_MAX = 150 + +/** Text case, in the order the case button cycles through (BO-09). */ +export const TEXT_CASES: TweakStyle['textCase'][] = ['none', 'lower', 'upper', 'capitalize'] + +/** The advanced sliders' range (BO-05), in percent; 100 changes nothing. */ +export const SLIDER_MIN = 50 +export const SLIDER_MAX = 150 +export const SATURATION_MAX = 200 + +/** Limits on what a Tweak holds. */ +export const TWEAK_LIMITS = { name: 80, code: 200_000, hidden: 400, selector: 2_000, label: 120 } as const + +export interface FontPreset { + id: string + label: string + /** A font stack of typefaces already on the computer: nothing is downloaded (BO-02, BO-07). */ + stack: string +} + +/** + * Font presets (BO-07): serif, sans-serif, monospace, rounded and handwritten styles. Each is a + * stack of fonts the system may have, ending in a generic family, so Orbit bundles no font files + * and a page never fetches one; the exact face differs between systems. + */ +export const FONT_PRESETS: readonly FontPreset[] = [ + { + id: 'sans', + label: 'Sans', + stack: 'system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", Cantarell, "Helvetica Neue", Arial, sans-serif', + }, + { + id: 'serif', + label: 'Serif', + stack: 'Charter, "Bitstream Charter", "Sitka Text", Cambria, "Noto Serif", "DejaVu Serif", Georgia, serif', + }, + { + id: 'classic', + label: 'Classic', + stack: '"Iowan Old Style", "Palatino Linotype", Palatino, "URW Palladio L", P052, "Book Antiqua", Georgia, serif', + }, + { + id: 'humanist', + label: 'Humanist', + stack: 'Seravek, "Gill Sans Nova", Ubuntu, Calibri, "DejaVu Sans", "Source Sans Pro", sans-serif', + }, + { + id: 'rounded', + label: 'Rounded', + stack: + 'ui-rounded, "SF Pro Rounded", "Hiragino Maru Gothic ProN", Quicksand, Comfortaa, Manjari, "Arial Rounded MT", "Arial Rounded MT Bold", Calibri, sans-serif', + }, + { + id: 'mono', + label: 'Mono', + stack: + 'ui-monospace, "SF Mono", "Cascadia Code", "Segoe UI Mono", Menlo, Consolas, "DejaVu Sans Mono", "Liberation Mono", monospace', + }, + { + id: 'typewriter', + label: 'Typewriter', + stack: '"American Typewriter", "Courier Prime", "Courier New", Courier, "Nimbus Mono PS", monospace', + }, + { + id: 'hand', + label: 'Hand', + stack: '"Comic Neue", "Comic Sans MS", "Chalkboard SE", "Segoe Print", "Bradley Hand", Chilanka, cursive', + }, +] + +export const DEFAULT_TWEAK_STYLE: TweakStyle = { + hue: 220, + intensity: 0, + accent: 220, + invert: false, + contrast: 100, + brightness: 100, + saturation: 100, + font: '', + size: 100, + textCase: 'none', + hidden: [], +} + +/** A new Tweak for a host, named after it (BO-01), on, with nothing changed yet. */ +export function newTweak(id: string, host: string, now: number): Tweak { + return { + id, + host, + name: host, + enabled: true, + css: '', + js: '', + createdAt: now, + updatedAt: now, + subdomains: false, + jsAllowed: true, + jsInPrivate: false, + style: { ...DEFAULT_TWEAK_STYLE, hidden: [] }, + } +} + +const isRecord = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value) +const num = (value: unknown, fallback: number, min: number, max: number): number => + typeof value === 'number' && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback +const bool = (value: unknown, fallback: boolean): boolean => (typeof value === 'boolean' ? value : fallback) +const str = (value: unknown, max: number): string => (typeof value === 'string' ? value.slice(0, max) : '') +/** Degrees in [0, 360). */ +export const wrapHue = (deg: number): number => ((deg % 360) + 360) % 360 + +/** A host Tweaks can be keyed by: a lowercase hostname, no port, no trailing dot. */ +export function isTweakHost(host: unknown): host is string { + return typeof host === 'string' && host.length > 0 && host.length <= 253 && /^[a-z0-9._\-[\]:]+$/.test(host) +} + +/** The host a page's Tweaks are keyed by (BO-17), or null where Tweaks never apply (BO-01, BO-18). */ +export function tweakHostOf(url: string | undefined | null): string | null { + if (!url) return null + let parsed: URL + try { + parsed = new URL(url) + } catch { + return null + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null + const host = parsed.hostname.toLowerCase().replace(/\.$/, '') + return isTweakHost(host) ? host : null +} + +function normalizeHidden(raw: unknown): TweakHidden[] { + if (!Array.isArray(raw)) return [] + const out: TweakHidden[] = [] + const seen = new Set() + for (const item of raw) { + if (!isRecord(item)) continue + const selector = str(item.selector, TWEAK_LIMITS.selector).trim() + const id = str(item.id, 64) || `h${out.length + 1}` + if (!isSafeSelector(selector) || seen.has(id)) continue + seen.add(id) + out.push({ id, selector, label: str(item.label, TWEAK_LIMITS.label) }) + if (out.length >= TWEAK_LIMITS.hidden) break + } + return out +} + +/** A saved style, each field checked and clamped; anything missing takes its default. */ +export function normalizeTweakStyle(raw: unknown): TweakStyle { + const s = isRecord(raw) ? raw : {} + const d = DEFAULT_TWEAK_STYLE + const textCase = TEXT_CASES.includes(s.textCase as TweakStyle['textCase']) + ? (s.textCase as TweakStyle['textCase']) + : d.textCase + const font = typeof s.font === 'string' && FONT_PRESETS.some((f) => f.id === s.font) ? s.font : '' + return { + hue: wrapHue(num(s.hue, d.hue, -1e6, 1e6)), + intensity: num(s.intensity, d.intensity, 0, 1), + accent: wrapHue(num(s.accent, d.accent, -1e6, 1e6)), + invert: bool(s.invert, d.invert), + contrast: num(s.contrast, d.contrast, SLIDER_MIN, SLIDER_MAX), + brightness: num(s.brightness, d.brightness, SLIDER_MIN, SLIDER_MAX), + saturation: num(s.saturation, d.saturation, 0, SATURATION_MAX), + font, + size: num(s.size, d.size, TWEAK_SIZE_MIN, TWEAK_SIZE_MAX), + textCase, + hidden: normalizeHidden(s.hidden), + } +} + +/** A saved Tweak, or null when it can't be one (no usable host). Missing fields take safe defaults. */ +export function normalizeTweak(id: string, raw: unknown): Tweak | null { + if (!isRecord(raw)) return null + const host = typeof raw.host === 'string' ? raw.host.toLowerCase().replace(/\.$/, '') : '' + if (!isTweakHost(host)) return null + const createdAt = num(raw.createdAt, 0, 0, Number.MAX_SAFE_INTEGER) + return { + id, + host, + name: str(raw.name, TWEAK_LIMITS.name).trim() || host, + enabled: bool(raw.enabled, true), + css: str(raw.css, TWEAK_LIMITS.code), + js: str(raw.js, TWEAK_LIMITS.code), + createdAt, + updatedAt: num(raw.updatedAt, createdAt, 0, Number.MAX_SAFE_INTEGER), + subdomains: bool(raw.subdomains, false), + // JavaScript runs only where allowed on this device (BO-18): not allowed unless saved so. + jsAllowed: bool(raw.jsAllowed, false), + jsInPrivate: bool(raw.jsInPrivate, false), + style: normalizeTweakStyle(raw.style), + } +} + +/** Every saved Tweak that can be one, keyed by its id. */ +export function normalizeTweaks(raw: unknown): Record { + const out: Record = {} + if (!isRecord(raw)) return out + for (const [id, value] of Object.entries(raw)) { + const tweak = normalizeTweak(id, value) + if (tweak) out[id] = tweak + } + return out +} + +/** The Tweak settings of a saved state, checked (BO-16, BO-01). */ +export function normalizeTweakSettings(settings: Settings): void { + if (typeof settings.tweaksEnabled !== 'boolean') settings.tweaksEnabled = true + if (typeof settings.tweakIntroSeen !== 'boolean') settings.tweakIntroSeen = false +} + +/** What an edit may change: the editor's controls (BO-02). Hidden elements have commands of their own. */ +export interface TweakPatch { + name?: string + enabled?: boolean + css?: string + js?: string + subdomains?: boolean + jsAllowed?: boolean + jsInPrivate?: boolean + style?: Partial> +} + +/** + * Applies a checked edit to a Tweak (a draft in a store update). Values out of range are clamped, + * values of the wrong type ignored. JavaScript written here is JavaScript allowed here (BO-11). + * True when something changed. + */ +export function applyTweakPatch(tweak: Tweak, patch: TweakPatch, now: number): boolean { + if (!isRecord(patch)) return false + let changed = false + const set = (key: K, value: Tweak[K]) => { + if (tweak[key] === value) return + tweak[key] = value + changed = true + } + if (typeof patch.name === 'string') set('name', patch.name.trim().slice(0, TWEAK_LIMITS.name) || tweak.host) + if (typeof patch.enabled === 'boolean') set('enabled', patch.enabled) + if (typeof patch.css === 'string') set('css', patch.css.slice(0, TWEAK_LIMITS.code)) + if (typeof patch.js === 'string') { + const js = patch.js.slice(0, TWEAK_LIMITS.code) + if (js !== tweak.js) { + set('js', js) + set('jsAllowed', true) + } + } + if (typeof patch.subdomains === 'boolean') set('subdomains', patch.subdomains) + if (typeof patch.jsAllowed === 'boolean') set('jsAllowed', patch.jsAllowed) + if (typeof patch.jsInPrivate === 'boolean') set('jsInPrivate', patch.jsInPrivate) + if (isRecord(patch.style)) { + const next = normalizeTweakStyle({ ...tweak.style, ...patch.style, hidden: tweak.style.hidden }) + const style = tweak.style + for (const key of Object.keys(next) as (keyof TweakStyle)[]) { + if (key === 'hidden' || style[key] === next[key]) continue + ;(style as unknown as Record)[key] = next[key] + changed = true + } + } + if (changed) tweak.updatedAt = now + return changed +} + +/** The colours, invert and sliders at their defaults (BO-06's reset). */ +export function colorReset(): Partial { + const d = DEFAULT_TWEAK_STYLE + return { + hue: d.hue, + intensity: d.intensity, + accent: d.accent, + invert: d.invert, + contrast: d.contrast, + brightness: d.brightness, + saturation: d.saturation, + } +} + +export function hasColor(style: TweakStyle): boolean { + return ( + style.intensity > 0 || + style.invert || + style.contrast !== 100 || + style.brightness !== 100 || + style.saturation !== 100 + ) +} + +/** The Tweak changes nothing at all yet. */ +export function isEmptyTweak(tweak: Tweak): boolean { + const s = tweak.style + return ( + !hasColor(s) && + !s.font && + s.size === 100 && + s.textCase === 'none' && + s.hidden.length === 0 && + !tweak.css.trim() && + !tweak.js.trim() + ) +} + +// ---- Matching (BO-17) ------------------------------------------------------- + +const indexes = new WeakMap, Map>() + +/** Oldest first, so a later Tweak's rules come later and win (BO-14). */ +const byCreated = (a: Tweak, b: Tweak) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0) + +/** Every Tweak by its host. Built once per version of the collection. */ +export function tweakIndex(tweaks: Record): Map { + let index = indexes.get(tweaks) + if (index) return index + index = new Map() + for (const tweak of Object.values(tweaks)) { + const list = index.get(tweak.host) + if (list) list.push(tweak) + else index.set(tweak.host, [tweak]) + } + for (const list of index.values()) list.sort(byCreated) + indexes.set(tweaks, index) + return index +} + +const isIpHost = (host: string) => host.includes(':') || /^\d+(\.\d+){3}$/.test(host) + +/** Whether a Tweak applies to pages of a host (its own, or a subdomain when it says so). */ +export function tweakAppliesTo(tweak: Tweak, host: string | null): boolean { + if (!host) return false + if (tweak.host === host) return true + return tweak.subdomains && !isIpHost(host) && host.endsWith(`.${tweak.host}`) +} + +/** + * The Tweaks for a host, on or off, oldest first (BO-14 lists them; `enabledTweaksFor` applies + * them): those made on the host, and those made on a parent domain that also apply to subdomains. + * One map lookup per label of the host. + */ +export function tweaksFor(tweaks: Record, host: string | null): Tweak[] { + if (!host) return [] + const index = tweakIndex(tweaks) + const own = index.get(host) + let out: Tweak[] | undefined + if (!isIpHost(host)) { + for (let dot = host.indexOf('.'); dot >= 0; dot = host.indexOf('.', dot + 1)) { + for (const tweak of index.get(host.slice(dot + 1)) ?? []) { + if (!tweak.subdomains) continue + out ??= own ? [...own] : [] + out.push(tweak) + } + } + } + if (!out) return own ?? [] + return out.sort(byCreated) +} + +/** The Tweaks that apply to a host now: on, and Tweaks on as a whole (BO-16). */ +export function enabledTweaksFor(tweaks: Record, settings: Settings, host: string | null): Tweak[] { + if (!settings.tweaksEnabled) return [] + return tweaksFor(tweaks, host).filter((t) => t.enabled) +} + +// ---- Colour (BO-03 to BO-06) ------------------------------------------------ + +type Mat = number[] +type Rgb = [number, number, number] + +const rad = (deg: number) => (deg * Math.PI) / 180 + +/** CSS filter functions as 3×3 matrices on sRGB (Filter Effects §: sepia, saturate, hue-rotate). */ +const sepiaM = (a: number): Mat => { + const k = 1 - a + return [ + 0.393 + 0.607 * k, + 0.769 - 0.769 * k, + 0.189 - 0.189 * k, + 0.349 - 0.349 * k, + 0.686 + 0.314 * k, + 0.168 - 0.168 * k, + 0.272 - 0.272 * k, + 0.534 - 0.534 * k, + 0.131 + 0.869 * k, + ] +} +const saturateM = (s: number): Mat => [ + 0.213 + 0.787 * s, + 0.715 - 0.715 * s, + 0.072 - 0.072 * s, + 0.213 - 0.213 * s, + 0.715 + 0.285 * s, + 0.072 - 0.072 * s, + 0.213 - 0.213 * s, + 0.715 - 0.715 * s, + 0.072 + 0.928 * s, +] +const hueM = (deg: number): Mat => { + const c = Math.cos(rad(deg)) + const s = Math.sin(rad(deg)) + return [ + 0.213 + c * 0.787 - s * 0.213, + 0.715 - c * 0.715 - s * 0.715, + 0.072 - c * 0.072 + s * 0.928, + 0.213 - c * 0.213 + s * 0.143, + 0.715 + c * 0.285 + s * 0.14, + 0.072 - c * 0.072 - s * 0.283, + 0.213 - c * 0.213 - s * 0.787, + 0.715 - c * 0.715 + s * 0.715, + 0.072 + c * 0.928 + s * 0.072, + ] +} + +/** One step of a filter chain: its CSS and the affine map it is (out = m·in + t). */ +export interface FilterStep { + css: string + m: Mat + t: Rgb +} + +const step = (css: string, m: Mat, t: Rgb = [0, 0, 0]): FilterStep => ({ css, m, t }) +const scale = (k: number): Mat => [k, 0, 0, 0, k, 0, 0, 0, k] +const round = (n: number, places = 3) => Number(n.toFixed(places)) + +/** How light white's tint is at full intensity, as HSL lightness (BO-03): a pastel of the hue. */ +const TINT_LIGHTNESS = 0.88 +/** The most sepia a tint mixes in: well below 1, so the tint stays invertible for images (BO-03). */ +const SEPIA_LIMIT = 0.75 + +function multiply(a: Mat, b: Mat): Mat { + const out: Mat = [] + for (let r = 0; r < 3; r++) + for (let c = 0; c < 3; c++) + out.push( + (a[r * 3] as number) * (b[c] as number) + + (a[r * 3 + 1] as number) * (b[3 + c] as number) + + (a[r * 3 + 2] as number) * (b[6 + c] as number), + ) + return out +} + +const apply = (m: Mat, v: Rgb): Rgb => [ + (m[0] as number) * v[0] + (m[1] as number) * v[1] + (m[2] as number) * v[2], + (m[3] as number) * v[0] + (m[4] as number) * v[1] + (m[5] as number) * v[2], + (m[6] as number) * v[0] + (m[7] as number) * v[1] + (m[8] as number) * v[2], +] +const clamp01 = (v: Rgb): Rgb => v.map((x) => Math.min(1, Math.max(0, x))) as Rgb + +/** Runs a colour through a chain as the engine does, clamping after each step. */ +export function runChain(chain: FilterStep[], rgb: Rgb): Rgb { + let v = rgb + for (const s of chain) { + const out = apply(s.m, v) + v = clamp01([out[0] + s.t[0], out[1] + s.t[1], out[2] + s.t[2]]) + } + return v +} + +/** What CSS colour filters keep: luma on encoded sRGB (hue-rotate and saturate leave it alone). */ +const luma = (v: Rgb) => 0.213 * v[0] + 0.715 * v[1] + 0.072 * v[2] + +function hslRgb(h: number, s: number, l: number): Rgb { + const f = (n: number) => { + const k = (n + h / 30) % 12 + return l - s * Math.min(l, 1 - l) * Math.max(-1, Math.min(k - 3, 9 - k, 1)) + } + return [f(0), f(8), f(4)] +} + +const hueOf = (v: Rgb): number => { + const [r, g, b] = v + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + if (max === min) return 0 + const d = max - min + const h = max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4 + return h * 60 +} +const spreadOf = (v: Rgb) => Math.max(...v) - Math.min(...v) +/** The signed difference of two hues, in (−180, 180]. */ +const hueDelta = (to: number, from: number) => 180 - wrapHue(180 - (to - from)) + +/** A tint's filter values (BO-03), as `tintSteps` prints them. */ +interface Tint { + /** Darkens first, so sepia can't push white past full red. */ + k: number + /** How much sepia: the tint's source. */ + p: number + /** Turns sepia's hue to the wheel's. */ + rotate: number + /** Gives the page's own colours back the colourfulness sepia took (1 / (1 − p)). */ + s: number + /** Sets the tint's lightness. */ + light: number +} + +function tintSteps(t: Tint, pre: number): FilterStep[] { + const steps: FilterStep[] = [] + const turn = round(pre, 1) + if (turn !== 0) steps.push(step(`hue-rotate(${turn}deg)`, hueM(turn))) + // Darkening goes first, so turning the hue has room below white; brightening goes last. + const first = t.light < 1 ? round(t.k * t.light) : t.k + steps.push(step(`brightness(${first})`, scale(first))) + steps.push(step(`sepia(${t.p})`, sepiaM(t.p))) + steps.push(step(`hue-rotate(${t.rotate}deg)`, hueM(t.rotate))) + steps.push(step(`saturate(${t.s})`, saturateM(t.s))) + if (t.light > 1) steps.push(step(`brightness(${t.light})`, scale(t.light))) + return steps +} + +/** A tint for a sepia amount: its other values follow from the hue and lightness wanted. */ +function tintFor(p: number, hue: number, lightness: number): Tint { + const k = round(1 / (1 + 0.351 * p)) + const s = round(1 / (1 - p)) + // Hue-rotate and saturate keep luma, so the last brightness alone sets the tint's lightness. + const sepiaWhite = runChain([step('', scale(k)), step('', sepiaM(p))], [1, 1, 1]) + const light = round(luma(hslRgb(hue, 1, lightness)) / luma(sepiaWhite)) + // Hue-rotate isn't an exact turn of HSL hue: correct the angle a few times. + let rotate = round(wrapHue(hue - hueOf(sepiaWhite)), 1) + for (let i = 0; i < 6; i++) { + const white = runChain(tintSteps({ k, p, rotate, s, light }, 0), [1, 1, 1]) + const miss = hueDelta(hue, hueOf(white)) + if (Math.abs(miss) < 0.25) break + rotate = round(wrapHue(rotate + miss), 1) + } + return { k, p, rotate, s, light } +} + +const tints = new Map() + +/** + * The tint for a wheel position (BO-03): white becomes a pastel of the hue, as light as intensity + * says, black stays black, and the page's own colours keep their colourfulness. Found by bisecting + * the sepia amount; cached by whole degree and hundredth of intensity, so a wheel drag mostly hits + * the cache. + */ +function solveTint(hue: number, intensity: number): Tint { + const h = Math.round(wrapHue(hue)) % 360 + const i = round(intensity, 2) + const key = `${h}:${i}` + const known = tints.get(key) + if (known) return known + const lightness = 1 - (1 - TINT_LIGHTNESS) * i + const wanted = spreadOf(hslRgb(h, 1, lightness)) + let lo = 0 + let hi = SEPIA_LIMIT + let best = tintFor(round(hi), h, lightness) + for (let n = 0; n < 14; n++) { + const p = (lo + hi) / 2 + const tint = tintFor(round(p), h, lightness) + const spread = spreadOf(runChain(tintSteps(tint, 0), [1, 1, 1])) + if (spread < wanted) lo = p + else { + hi = p + best = tint + } + } + if (tints.size > 4000) tints.clear() + tints.set(key, best) + return best +} + +/** + * The filter chain for a style's colours, applied to the page root (BO-03 to BO-05): + * - "original saturation" first, on the page's own colours; + * - the tint: darken a little, mix in sepia, turn it to the wheel's hue and saturate it, so white + * becomes a light tint, black stays black and text keeps its contrast. The rim handle turns the + * page's own colours by its distance from the main hue; + * - invert lightness (BO-04): invert, then turn hues back, so the tint becomes a dark one; + * - contrast and brightness. + */ +export function colorChain(style: TweakStyle): FilterStep[] { + const chain: FilterStep[] = [] + if (style.saturation !== 100) { + const s = round(style.saturation / 100) + chain.push(step(`saturate(${s})`, saturateM(s))) + } + if (style.intensity > 0) { + const tint = solveTint(style.hue, style.intensity) + // Page colours turn by (accent − hue) in all: by `pre` here, then by the tint's own turn. + chain.push(...tintSteps(tint, hueDelta(style.accent - style.hue, tint.rotate))) + } + if (style.invert) { + chain.push(step('invert(1)', scale(-1), [1, 1, 1])) + chain.push(step('hue-rotate(180deg)', hueM(180))) + } + if (style.contrast !== 100) { + const c = round(style.contrast / 100) + chain.push(step(`contrast(${c})`, scale(c), [0.5 - 0.5 * c, 0.5 - 0.5 * c, 0.5 - 0.5 * c])) + } + if (style.brightness !== 100) { + const b = round(style.brightness / 100) + chain.push(step(`brightness(${b})`, scale(b))) + } + return chain +} + +/** A chain as one affine map (no clamping). */ +export function composeChain(chain: FilterStep[]): { m: Mat; t: Rgb } { + let m: Mat = scale(1) + let t: Rgb = [0, 0, 0] + for (const s of chain) { + m = multiply(s.m, m) + const st = apply(s.m, t) + t = [st[0] + s.t[0], st[1] + s.t[1], st[2] + s.t[2]] + } + return { m, t } +} + +/** The inverse of an affine colour map, or null when it has none. */ +export function invertAffine({ m, t }: { m: Mat; t: Rgb }): { m: Mat; t: Rgb } | null { + const [a, b, c, d, e, f, g, h, i] = m as [number, number, number, number, number, number, number, number, number] + const det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g) + if (Math.abs(det) < 1e-6) return null + const inv: Mat = [ + (e * i - f * h) / det, + (c * h - b * i) / det, + (b * f - c * e) / det, + (f * g - d * i) / det, + (a * i - c * g) / det, + (c * d - a * f) / det, + (d * h - e * g) / det, + (b * g - a * h) / det, + (a * e - b * d) / det, + ] + const it = apply(inv, t) + return { m: inv, t: [-it[0], -it[1], -it[2]] } +} + +/** + * The filter that undoes a chain on images and video (BO-03, BO-04: they keep their own colours): + * an SVG colour matrix in a data URL. A page whose `img-src` policy blocks data URLs ignores it, and + * its images take the tint too. + */ +export function mediaFilter(chain: FilterStep[]): string | null { + const inverse = invertAffine(composeChain(chain)) + if (!inverse) return null + const { m, t } = inverse + const r = (n: number) => String(round(n, 4)) + const row = (k: number) => `${r(m[k * 3] as number)} ${r(m[k * 3 + 1] as number)} ${r(m[k * 3 + 2] as number)} 0 ${r(t[k] as number)}` + const values = `${row(0)} ${row(1)} ${row(2)} 0 0 0 1 0` + const svg = `` + return `url("data:image/svg+xml,${encodeURIComponent(svg)}#m")` +} + +/** + * What keeps its own colours under the page's filter (BO-03, BO-04): images, video, canvases, + * plugins, and frames that may go full screen or picture in picture (video players). Not inside an + * element in full screen, which the page's filter doesn't reach. + */ +const MEDIA = + ':is(img, video, canvas, embed, object, svg image, iframe:is([allowfullscreen], [allow*="fullscreen" i], [allow*="picture-in-picture" i], [allow*="encrypted-media" i])):not(:fullscreen, :fullscreen *)' + +/** + * Elements the root's filter doesn't reach: the top layer (modal dialogs, open popovers) is drawn + * outside the root, so it gets the filter of its own. Full screen keeps the original colours. + */ +const FILTERED = 'html, :modal, :popover-open' + +/** + * The colour part of a page's stylesheet, without its layer. A page that paints no background of + * its own gets the default canvas colour on the root, where the filter reaches it (the canvas + * behind the root isn't filtered); any background the page sets wins over it. + */ +export function colorCss(style: TweakStyle): string { + if (!hasColor(style)) return '' + const chain = colorChain(style) + if (chain.length === 0) return '' + const media = mediaFilter(chain) + const rules = [ + 'html { background-color: Canvas; }', + `${FILTERED} { filter: ${chain.map((s) => s.css).join(' ')} !important; }`, + ] + if (media) rules.push(`${MEDIA} { filter: ${media} !important; }`) + return rules.join('\n') +} + +// ---- Fonts, size, case, hidden elements (BO-07 to BO-10) -------------------- + +/** + * What a font preset leaves alone (BO-07): icon fonts (by the usual element and class names, and + * glyphs hidden from screen readers), code, and math. + */ +const FONT_SKIP = [ + 'i:empty', + '[class*="icon" i]', + '[class*="glyph" i]', + '[class*="symbol" i]', + '[class^="fa"]', + '[class*=" fa"]', + '[class*="material" i]', + '[data-icon]', + '[aria-hidden="true"]', + 'svg', + 'svg *', + 'code', + 'code *', + 'pre', + 'pre *', + 'kbd', + 'samp', + 'tt', + 'math', + 'math *', +].join(', ') + +/** Fields and code keep their case (BO-09). */ +const CASE_KEEP = + 'input, textarea, select, [contenteditable]:not([contenteditable="false"]), [contenteditable]:not([contenteditable="false"]) *, code, code *, pre, pre *, kbd, samp' + +const CASE_VALUE: Record = { + none: 'none', + lower: 'lowercase', + upper: 'uppercase', + capitalize: 'capitalize', +} + +export function fontCss(font: string): string { + const preset = FONT_PRESETS.find((f) => f.id === font) + if (!preset) return '' + return `body, body *:not(${FONT_SKIP}) { font-family: ${preset.stack} !important; }` +} + +export function sizeCss(size: number): string { + if (size === 100) return '' + return `html { zoom: ${round(size / 100)} !important; }` +} + +export function caseCss(textCase: TweakStyle['textCase']): string { + if (textCase === 'none') return '' + return `body, body * { text-transform: ${CASE_VALUE[textCase]} !important; }\nbody :is(${CASE_KEEP}) { text-transform: none !important; }` +} + +/** One rule per hidden element: a selector that doesn't parse drops only its own rule. */ +export function hiddenCss(hidden: readonly TweakHidden[], skip?: string): string { + return hidden + .filter((h) => h.id !== skip && isSafeSelector(h.selector)) + .map((h) => `${h.selector} { display: none !important; }`) + .join('\n') +} + +// ---- Custom CSS (BO-11) ----------------------------------------------------- + +/** At-rules whose blocks hold more rules. */ +const GROUP_RULES = new Set(['media', 'supports', 'layer', 'container', 'document', '-moz-document', 'scope', 'starting-style']) +/** At-rules whose blocks hold keyframes: their declarations can't be important. */ +const KEYFRAMES = /^(-webkit-|-moz-|-o-)?keyframes$/ + +type Block = 'rules' | 'decls' | 'raw' | 'keyframes' + +/** The end of a string that starts at `i` (past its closing quote), and whether it was closed. */ +function stringEnd(css: string, i: number): { end: number; closed: boolean } { + const quote = css[i] + let j = i + 1 + while (j < css.length) { + const c = css[j] + if (c === '\\') j += 2 + else if (c === quote) return { end: j + 1, closed: true } + else if (c === '\n') return { end: j, closed: true } // CSS ends a string at a line break + else j++ + } + return { end: css.length, closed: false } +} + +/** The end of a comment that starts at `i`, and whether it was closed. */ +function commentEnd(css: string, i: number): { end: number; closed: boolean } { + const stop = css.indexOf('*/', i + 2) + return stop < 0 ? { end: css.length, closed: false } : { end: stop + 2, closed: true } +} + +/** + * A group in brackets from `i` (url(), :is(), attribute selectors, which may hold `;` and quotes): + * its text, closed where it was left open (at a brace or the end), so it can't swallow what follows. + */ +function groupAt(css: string, i: number): { text: string; end: number } { + const closers: string[] = [] + let j = i + let text = '' + while (j < css.length) { + const c = css[j] as string + if (c === '"' || c === "'") { + const str = stringEnd(css, j) + text += css.slice(j, str.end) + (str.closed ? '' : c) + j = str.end + continue + } + if (c === '/' && css[j + 1] === '*') { + const comment = commentEnd(css, j) + text += css.slice(j, comment.end) + (comment.closed ? '' : '*/') + j = comment.end + continue + } + if (c === '{' || c === '}') break + text += c + j++ + if (c === '(') closers.push(')') + else if (c === '[') closers.push(']') + else if ((c === ')' || c === ']') && closers[closers.length - 1] === c) { + closers.pop() + if (closers.length === 0) return { text, end: j } + } + } + return { text: text + closers.reverse().join(''), end: j } +} + +/** + * A Tweak's own CSS with `!important` added to each declaration of a style rule (nested ones too), + * so it wins over the page's like the rest of the Tweak. Descriptors (`@font-face`, `@property`…) + * and keyframes are left as written, where `!important` isn't allowed. What was left open (blocks, + * brackets, strings, comments) is closed, so half-typed CSS can't swallow the rules after it. + */ +export function withImportant(css: string): string { + let out = '' + let chunk = '' + const stack: Block[] = ['rules'] + const top = () => stack[stack.length - 1] as Block + /** The declaration gathered so far, made important if it is one. */ + const endDeclaration = () => { + const bare = stripComments(chunk).trim() + if (top() === 'decls' && /^[\w-]+\s*:/.test(bare) && !/!\s*important$/i.test(bare)) { + const trailing = /\s*$/.exec(chunk)?.[0] ?? '' + out += `${chunk.slice(0, chunk.length - trailing.length)} !important${trailing}` + } else out += chunk + chunk = '' + } + let i = 0 + const n = css.length + while (i < n) { + const ch = css[i] as string + if (ch === '/' && css[i + 1] === '*') { + const comment = commentEnd(css, i) + chunk += css.slice(i, comment.end) + (comment.closed ? '' : '*/') + i = comment.end + continue + } + if (ch === '"' || ch === "'") { + const str = stringEnd(css, i) + chunk += css.slice(i, str.end) + (str.closed ? '' : ch) + i = str.end + continue + } + if (ch === '(' || ch === '[') { + const group = groupAt(css, i) + chunk += group.text + i = group.end + continue + } + if (ch === ';') { + if (top() === 'decls') endDeclaration() + else { + out += chunk + chunk = '' + } + out += ';' + i++ + continue + } + if (ch === '{') { + const prelude = stripComments(chunk).trim() + const block = top() + let next: Block + if (block === 'raw' || block === 'keyframes') next = 'raw' + else if (prelude.startsWith('@')) { + const name = /^@([\w-]+)/.exec(prelude)?.[1]?.toLowerCase() ?? '' + next = GROUP_RULES.has(name) ? 'rules' : KEYFRAMES.test(name) ? 'keyframes' : name === 'page' ? 'decls' : 'raw' + } else next = 'decls' + out += `${chunk}{` + chunk = '' + stack.push(next) + i++ + continue + } + if (ch === '}') { + i++ + // A closing brace with nothing open is dropped. + if (stack.length === 1) continue + if (top() === 'decls') endDeclaration() + else { + out += chunk + chunk = '' + } + out += '}' + stack.pop() + continue + } + chunk += ch + i++ + } + if (top() === 'decls') endDeclaration() + else out += chunk + out += '}'.repeat(stack.length - 1) + return out +} + +function stripComments(text: string): string { + return text.replace(/\/\*[\s\S]*?(\*\/|$)/g, '') +} + +/** + * A selector Hide-element mode may save (BO-10): one that can't reach outside its own rule. No + * braces, semicolons or comments, quotes and brackets closed, and no backslash at the end. + */ +export function isSafeSelector(selector: string): boolean { + if (!selector || selector.length > TWEAK_LIMITS.selector || /[\n\r\f]/.test(selector)) return false + const closers: string[] = [] + for (let i = 0; i < selector.length; i++) { + const c = selector[i] as string + if (c === '\\') { + // An escape takes the next character, and there must be one. + if (++i >= selector.length) return false + continue + } + if (c === '"' || c === "'") { + const str = stringEnd(selector, i) + if (!str.closed || selector[str.end - 1] !== c) return false + i = str.end - 1 + continue + } + if (c === '{' || c === '}' || c === ';' || (c === '/' && selector[i + 1] === '*')) return false + if (c === '(') closers.push(')') + else if (c === '[') closers.push(']') + else if (c === ')' || c === ']') { + if (closers.pop() !== c) return false + } + } + return closers.length === 0 +} + +// ---- Space colours (BO-19) --------------------------------------------------- + +/** The Space colours a Tweak's CSS can use, by variable name (documented in the editor). */ +export type ThemeVariables = Record + +/** `var(--orbit-…)` replaced by the colour (or its fallback), so the page never sees the variables. */ +export function withThemeVariables(css: string, vars: ThemeVariables | undefined): string { + if (!css.includes('--orbit-')) return css + return css.replace(/var\(\s*(--orbit-[\w-]+)\s*(?:,\s*([^()]*(?:\([^()]*\)[^()]*)*))?\)/g, (all, name: string, fallback?: string) => { + const value = vars?.[name] + if (value) return value + return fallback !== undefined ? fallback.trim() : all + }) +} + +// ---- A page's stylesheets --------------------------------------------------- + +/** + * One of a page's Tweak stylesheets, its CSS inside the Tweak layer. A page gets several, each + * inserted on its own, so a change replaces only its own sheet and a rule left broken in one Tweak's + * CSS can't reach another's: + * - `color`: the colour wheel, invert and sliders. It changes at drag rate, and it is small; + * - `base`: fonts, size and case; + * - `t:`: each Tweak's hidden elements and own CSS. + */ +export interface SheetPart { + key: string + css: string +} + +/** CSS in the Tweak layer. */ +export function layered(css: string): string { + return `@layer ${TWEAK_LAYER} {\n${css}\n}` +} + +export interface SheetOptions { + /** The Space colours for `var(--orbit-…)` (BO-19). */ + theme?: ThemeVariables + /** A hidden element shown again for now (BO-10's restore marker, while hovered), by its id. */ + showHidden?: string +} + +const ownCssCache = new WeakMap() + +function ownCss(tweak: Tweak): string { + let css = ownCssCache.get(tweak) + if (css === undefined) { + css = tweak.css.trim() ? withImportant(tweak.css) : '' + ownCssCache.set(tweak, css) + } + return css +} + +/** + * The stylesheets for a page from the Tweaks that apply to it (on, oldest first). Colours, font, + * size and case come from the newest Tweak that sets each; hidden elements and custom CSS from + * every one, in order, so a later Tweak's rules come later (BO-14). + */ +export function tweakSheets(tweaks: readonly Tweak[], opts: SheetOptions = {}): SheetPart[] { + if (tweaks.length === 0) return [] + let color: TweakStyle | undefined + let font = '' + let size = 100 + let textCase: TweakStyle['textCase'] = 'none' + for (const tweak of tweaks) { + const s = tweak.style + if (hasColor(s)) color = s + if (s.font) font = s.font + if (s.size !== 100) size = s.size + if (s.textCase !== 'none') textCase = s.textCase + } + const parts: SheetPart[] = [] + const colors = color ? colorCss(color) : '' + if (colors) parts.push({ key: 'color', css: layered(colors) }) + const base = [fontCss(font), sizeCss(size), caseCss(textCase)].filter(Boolean).join('\n') + if (base) parts.push({ key: 'base', css: layered(base) }) + for (const tweak of tweaks) { + const own = ownCss(tweak) + const css = [hiddenCss(tweak.style.hidden, opts.showHidden), own && withThemeVariables(own, opts.theme)] + .filter(Boolean) + .join('\n') + if (css) parts.push({ key: `t:${tweak.id}`, css: layered(css) }) + } + return parts +} + +// ---- JavaScript (BO-11, BO-18) ------------------------------------------------ + +/** + * The code main runs in the page for a Tweak: its JavaScript in a function of its own, called with + * the window as `this`. An error it throws goes to the page's console under the Tweak's name; one it + * can't even start with (a syntax error) the engine reports there itself. + */ +export function tweakScript(tweak: Pick): string { + const label = JSON.stringify(`[Tweak “${tweak.name}”]`) + return `void (function () {\ntry {\n${tweak.js}\n} catch (error) {\nconsole.error(${label}, error)\n}\n}).call(window)` +} + +/** Colour helpers for the editor's wheel: a hue and intensity as a CSS colour. */ +export function wheelColor(hue: number, intensity: number): string { + return `hsl(${round(wrapHue(hue), 1)} ${round(30 + 60 * intensity, 1)}% ${round(70 - 20 * intensity, 1)}%)` +} + +/** The hue of a hex colour, for seeding the wheel from a Space colour. */ +export function hueOfHex(hex: string): number { + return hexToHsl(hex).h +} diff --git a/tests/unit/tweaks-logic.test.ts b/tests/unit/tweaks-logic.test.ts new file mode 100644 index 00000000..976362e3 --- /dev/null +++ b/tests/unit/tweaks-logic.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, it } from 'vitest' +import { normalizeState } from '../../src/main/persist' +import { createInitialState, DEFAULT_SETTINGS } from '../../src/shared/defaults' +import type { OrbitState, Tweak } from '../../src/shared/state' +import { + applyTweakPatch, + colorChain, + colorCss, + composeChain, + DEFAULT_TWEAK_STYLE, + enabledTweaksFor, + FONT_PRESETS, + fontCss, + invertAffine, + isEmptyTweak, + isSafeSelector, + newTweak, + normalizeTweak, + normalizeTweaks, + runChain, + tweakAppliesTo, + tweakHostOf, + tweakIndex, + tweakScript, + tweakSheets, + tweaksFor, + withImportant, + withThemeVariables, +} from '../../src/shared/tweaks' + +type Rgb = [number, number, number] + +const tweak = (id: string, host: string, patch: Partial = {}, createdAt = 1): Tweak => ({ + ...newTweak(id, host, createdAt), + ...patch, +}) +const record = (...list: Tweak[]) => Object.fromEntries(list.map((t) => [t.id, t])) + +function hsl([r, g, b]: Rgb): { h: number; s: number; l: number } { + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + const l = (max + min) / 2 + if (max === min) return { h: 0, s: 0, l } + const d = max - min + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min) + const h = max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4 + return { h: h * 60, s, l } +} +const hueGap = (a: number, b: number) => Math.abs(((a - b + 540) % 360) - 180) +const lin = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4) +const luminance = ([r, g, b]: Rgb) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b) +const contrast = (a: Rgb, b: Rgb) => { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number] + return (hi + 0.05) / (lo + 0.05) +} +const near = (a: Rgb, b: Rgb, tolerance: number) => a.every((v, i) => Math.abs(v - (b[i] as number)) <= tolerance) +const WHITE: Rgb = [1, 1, 1] +const BLACK: Rgb = [0, 0, 0] + +describe('[BO-17] which pages a Tweak applies to', () => { + it('keys a page by its lowercase hostname, without the port, on websites only', () => { + expect(tweakHostOf('https://WWW.Example.com:8443/a/b?x=1#y')).toBe('www.example.com') + expect(tweakHostOf('http://example.com./')).toBe('example.com') + expect(tweakHostOf('http://127.0.0.1:3300/x')).toBe('127.0.0.1') + expect(tweakHostOf('http://[::1]:3300/')).toBe('[::1]') + for (const url of ['orbit://settings/', 'file:///tmp/a.html', 'about:blank', 'data:text/html,x', 'chrome://gpu', '', 'nonsense']) + expect(tweakHostOf(url)).toBeNull() + }) + + it('applies on every page of its host, and not on another site', () => { + const t = tweak('a', 'www.example.com') + expect(tweakAppliesTo(t, tweakHostOf('https://www.example.com/a/b?x=1'))).toBe(true) + expect(tweakAppliesTo(t, tweakHostOf('https://example.org/'))).toBe(false) + expect(tweakAppliesTo(t, tweakHostOf('https://example.com/'))).toBe(false) + expect(tweakAppliesTo(t, tweakHostOf('https://news.www.example.com/'))).toBe(false) + }) + + it('reaches subdomains only when it says so, and never by the numbers of an address', () => { + const t = tweak('a', 'example.com', { subdomains: true }) + expect(tweakAppliesTo(t, 'news.example.com')).toBe(true) + expect(tweakAppliesTo(t, 'a.b.example.com')).toBe(true) + expect(tweakAppliesTo(t, 'badexample.com')).toBe(false) + const ip = tweak('b', '0.0.1', { subdomains: true }) + expect(tweaksFor(record(ip), '127.0.0.1')).toEqual([]) + }) + + it('finds a host’s Tweaks through an index built once per collection, oldest first', () => { + const older = tweak('b', 'example.com', {}, 5) + const newer = tweak('a', 'example.com', {}, 9) + const parent = tweak('c', 'example.com', { subdomains: true }, 7) + const other = tweak('d', 'example.org', {}, 1) + const all = record(newer, older, other) + expect(tweakIndex(all)).toBe(tweakIndex(all)) + expect(tweaksFor(all, 'example.com').map((t) => t.id)).toEqual(['b', 'a']) + const withParent = record(newer, older, parent, tweak('e', 'news.example.com', {}, 6)) + expect(tweaksFor(withParent, 'news.example.com').map((t) => t.id)).toEqual(['e', 'c']) + expect(tweaksFor(withParent, null)).toEqual([]) + }) + + it('applies none while Tweaks are off as a whole, and skips those switched off (BO-14, BO-16)', () => { + const on = tweak('a', 'example.com') + const off = tweak('b', 'example.com', { enabled: false }) + const all = record(on, off) + expect(enabledTweaksFor(all, DEFAULT_SETTINGS, 'example.com').map((t) => t.id)).toEqual(['a']) + expect(enabledTweaksFor(all, { ...DEFAULT_SETTINGS, tweaksEnabled: false }, 'example.com')).toEqual([]) + }) +}) + +describe('[BO-01][BO-18] the saved Tweak', () => { + it('starts named after its host, on, changing nothing, its JavaScript allowed on this device', () => { + const t = newTweak('t1', 'news.example.com', 42) + expect(t).toMatchObject({ name: 'news.example.com', enabled: true, subdomains: false, jsAllowed: true, jsInPrivate: false }) + expect(t.style).toEqual(DEFAULT_TWEAK_STYLE) + expect(isEmptyTweak(t)).toBe(true) + }) + + it('loads with defaults for what is missing, clamped values, and no JavaScript allowed unless saved so', () => { + const t = normalizeTweak('x', { + host: 'Example.COM.', + js: 'console.log(1)', + style: { size: 500, intensity: 2, hue: -30, font: 'nope', textCase: 'shout', hidden: [{ id: 'h', selector: '#ad' }, { selector: 'a{' }] }, + }) + expect(t).toMatchObject({ host: 'example.com', name: 'example.com', enabled: true, jsAllowed: false, subdomains: false }) + expect(t?.style).toMatchObject({ size: 150, intensity: 1, hue: 330, font: '', textCase: 'none' }) + expect(t?.style.hidden).toEqual([{ id: 'h', selector: '#ad', label: '' }]) + expect(normalizeTweak('y', { host: '' })).toBeNull() + expect(normalizeTweak('y', 'text')).toBeNull() + expect(Object.keys(normalizeTweaks({ a: { host: 'a.com' }, b: null, c: { host: 'b c' } }))).toEqual(['a']) + }) + + it('loads a state file from before Tweaks had their controls, and the switches default on and unseen', () => { + const raw = createInitialState(0) as unknown as Record + raw.tweaks = { old: { id: 'old', host: 'example.com', name: 'Mine', enabled: false, css: 'a{}', js: '', createdAt: 3 } } + const settings = raw.settings as Record + delete settings.tweaksEnabled + delete settings.tweakIntroSeen + const state = normalizeState(JSON.parse(JSON.stringify(raw)) as Partial) + expect(state.tweaks.old).toMatchObject({ name: 'Mine', enabled: false, css: 'a{}', updatedAt: 3, style: DEFAULT_TWEAK_STYLE }) + expect(state.settings.tweaksEnabled).toBe(true) + expect(state.settings.tweakIntroSeen).toBe(false) + }) + + it('takes edits checked: clamped, typed, and JavaScript written here is allowed here', () => { + const t = { ...newTweak('t', 'example.com', 1), jsAllowed: false } + expect(applyTweakPatch(t, { style: { size: 999, hue: 'red' as unknown as number } }, 5)).toBe(true) + expect(t.style.size).toBe(150) + expect(t.style.hue).toBe(DEFAULT_TWEAK_STYLE.hue) + expect(t.updatedAt).toBe(5) + expect(applyTweakPatch(t, { style: { size: 150 } }, 9)).toBe(false) + expect(t.updatedAt).toBe(5) + applyTweakPatch(t, { js: 'document.title = "x"' }, 10) + expect(t.jsAllowed).toBe(true) + applyTweakPatch(t, { name: ' ' }, 11) + expect(t.name).toBe('example.com') + applyTweakPatch(t, { style: { hidden: [] } as never }, 12) + expect(t.style.hidden).toEqual([]) + }) +}) + +describe('[BO-11] custom CSS wins over the page', () => { + it('makes each declaration important', () => { + expect(withImportant('body { background: yellow }')).toBe('body { background: yellow !important }') + expect(withImportant('a{color:red;b:1}')).toBe('a{color:red !important;b:1 !important}') + expect(withImportant('a { color: red !important; }')).toBe('a { color: red !important; }') + expect(withImportant('a { --x: 1px }')).toBe('a { --x: 1px !important }') + }) + + it('in nested rules and grouping rules too, and nowhere it isn’t allowed', () => { + expect(withImportant('@media (max-width: 600px) { a { color: red } }')).toBe( + '@media (max-width: 600px) { a { color: red !important } }', + ) + expect(withImportant('a { color: red; &:hover { color: blue } }')).toBe( + 'a { color: red !important; &:hover { color: blue !important } }', + ) + const keep = [ + '@keyframes spin { from { transform: rotate(0) } to { transform: rotate(1turn) } }', + '@font-face { font-family: X; src: url(a.woff2) }', + '@property --y { syntax: ""; inherits: false; initial-value: 0px }', + '@import url("x.css");', + ] + for (const css of keep) expect(withImportant(css)).toBe(css) + }) + + it('reads strings, URLs and selectors with braces or semicolons in them as one piece', () => { + expect(withImportant('a { background: url(data:image/png;base64,AA) }')).toBe( + 'a { background: url(data:image/png;base64,AA) !important }', + ) + expect(withImportant('a[title="{;}"] { content: "};{" }')).toBe('a[title="{;}"] { content: "};{" !important }') + }) + + it('closes what was left open, so half-typed CSS swallows nothing after it', () => { + expect(withImportant('a { color: red')).toBe('a { color: red !important}') + expect(withImportant('a { content: "x')).toBe('a { content: "x" !important}') + expect(withImportant('a { color: red } /* note')).toBe('a { color: red !important } /* note*/') + expect(withImportant('a { background: url(x')).toBe('a { background: url(x) !important}') + expect(withImportant('a:is(.b { color: red }')).toBe('a:is(.b ){ color: red !important }') + expect(withImportant('} a { color: red }')).toBe(' a { color: red !important }') + }) +}) + +describe('[BO-10] what Hide-element mode saves', () => { + it('is a selector that can’t reach outside its own rule', () => { + for (const ok of ['#main > .ad', 'a[href*="x;y"]', 'div:nth-child(2)', ':is(a, b) span', 'p.a\\:b']) + expect(isSafeSelector(ok), ok).toBe(true) + for (const bad of ['a{', 'a;b', 'a /* x', 'a[', 'a:not(b', '"x', 'a\\', 'a ]', 'a\nb', '', '[title="a\nb"]']) + expect(isSafeSelector(bad), bad).toBe(false) + }) +}) + +describe('[BO-03][BO-04][BO-05][BO-06] colours', () => { + const style = (patch: Partial) => ({ ...DEFAULT_TWEAK_STYLE, ...patch }) + + it('change nothing at their defaults (BO-06’s reset)', () => { + expect(colorChain(DEFAULT_TWEAK_STYLE)).toEqual([]) + expect(colorCss(DEFAULT_TWEAK_STYLE)).toBe('') + }) + + it('tint white to a pastel of the wheel’s hue, as light as the intensity says, and keep black', () => { + for (const hue of [0, 30, 60, 120, 180, 220, 270, 320]) + for (const intensity of [0.25, 0.5, 1]) { + const chain = colorChain(style({ hue, accent: hue, intensity })) + const white = hsl(runChain(chain, WHITE)) + expect(hueGap(white.h, hue), `hue ${hue} at ${intensity}`).toBeLessThan(4) + expect(Math.abs(white.l - (1 - 0.12 * intensity)), `lightness ${hue} at ${intensity}`).toBeLessThan(0.02) + expect(near(runChain(chain, BLACK), BLACK, 2 / 255)).toBe(true) + } + }) + + it('keep text readable on its background, tinted or inverted', () => { + for (const hue of [0, 60, 120, 220, 300]) + for (const invert of [false, true]) { + const chain = colorChain(style({ hue, accent: hue, intensity: 1, invert })) + expect(contrast(runChain(chain, WHITE), runChain(chain, BLACK)), `hue ${hue}`).toBeGreaterThan(7) + expect(contrast(runChain(chain, WHITE), runChain(chain, [0.2, 0.2, 0.2])), `hue ${hue}`).toBeGreaterThan(4.5) + } + }) + + it('invert lightness: white becomes black, black white, and greys keep their place (BO-04)', () => { + const chain = colorChain(style({ invert: true })) + expect(near(runChain(chain, WHITE), BLACK, 1 / 255)).toBe(true) + expect(near(runChain(chain, BLACK), WHITE, 1 / 255)).toBe(true) + expect(near(runChain(chain, [0.5, 0.5, 0.5]), [0.5, 0.5, 0.5], 1 / 255)).toBe(true) + const blue = hsl(runChain(chain, [0.2, 0.4, 0.8])) + expect(hueGap(blue.h, hsl([0.2, 0.4, 0.8]).h)).toBeLessThan(3) + }) + + it('turn the page’s own colours by the rim handle’s distance from the main hue', () => { + const red: Rgb = [0.8, 0.2, 0.2] + const kept = hsl(runChain(colorChain(style({ hue: 220, accent: 220, intensity: 0.5 })), red)).h + expect(hueGap(kept, 0)).toBeLessThan(15) + const turned = hsl(runChain(colorChain(style({ hue: 220, accent: 340, intensity: 0.5 })), red)).h + expect(hueGap(turned, 120)).toBeLessThan(25) + }) + + it('give images and video a filter that undoes the page’s, for their usual colours', () => { + let seed = 3 + const random = () => { + seed = (seed * 16807) % 2147483647 + return seed / 2147483647 + } + for (const s of [style({ invert: true }), style({ hue: 220, accent: 220, intensity: 0.5 }), style({ contrast: 120, brightness: 90 })]) { + const chain = colorChain(s) + const inverse = invertAffine(composeChain(chain)) + expect(inverse).not.toBeNull() + if (!inverse) continue + for (let n = 0; n < 300; n++) { + // A photo's colours: muted, mid lightness. + const grey = 0.25 + random() * 0.5 + const x: Rgb = [grey + (random() - 0.5) * 0.2, grey + (random() - 0.5) * 0.2, grey + (random() - 0.5) * 0.2] + const undone = [0, 1, 2].map((r) => + Math.min(1, Math.max(0, inverse.m[r * 3]! * x[0] + inverse.m[r * 3 + 1]! * x[1] + inverse.m[r * 3 + 2]! * x[2] + inverse.t[r]!)), + ) as Rgb + expect(near(runChain(chain, undone), x, 4 / 255)).toBe(true) + } + } + expect(colorCss(style({ invert: true }))).toContain('feColorMatrix') + }) + + it('reach the top layer and a page without a background of its own', () => { + const css = colorCss(style({ invert: true })) + expect(css).toContain('html { background-color: Canvas; }') + expect(css).toMatch(/html, :modal, :popover-open \{ filter: invert\(1\) hue-rotate\(180deg\) !important; \}/) + }) +}) + +describe('[BO-07][BO-08][BO-09][BO-14] a page’s stylesheets', () => { + it('are in the Tweak layer, one for the colours, one for type and one per Tweak', () => { + const a = tweak('a', 'example.com', { css: 'body { color: red }', style: { ...DEFAULT_TWEAK_STYLE, size: 110, invert: true } }, 1) + const b = tweak('b', 'example.com', { style: { ...DEFAULT_TWEAK_STYLE, size: 125, hidden: [{ id: 'h', selector: '.ad', label: 'Ad' }] } }, 2) + const parts = tweakSheets([a, b]) + expect(parts.map((p) => p.key)).toEqual(['color', 'base', 't:a', 't:b']) + for (const part of parts) expect(part.css.startsWith('@layer orbit-tweaks {')).toBe(true) + expect(parts[1]?.css).toContain('zoom: 1.25 !important') + expect(parts[2]?.css).toContain('body { color: red !important }') + expect(parts[3]?.css).toContain('.ad { display: none !important; }') + expect(tweakSheets([b], { showHidden: 'h' }).map((p) => p.key)).toEqual(['base']) + expect(tweakSheets([])).toEqual([]) + }) + + it('leave icon fonts and code in their own fonts', () => { + expect(FONT_PRESETS.length).toBeGreaterThanOrEqual(6) + const css = fontCss('serif') + expect(css).toContain('[class*="icon" i]') + expect(css).toContain('code') + expect(css).toMatch(/serif !important; \}$/) + expect(fontCss('')).toBe('') + }) + + it('put the Space colours in place of their variables, so the page never sees them (BO-19)', () => { + const vars = { '--orbit-theme-accent': '#ff0000' } + expect(withThemeVariables('a { color: var(--orbit-theme-accent) }', vars)).toBe('a { color: #ff0000 }') + expect(withThemeVariables('a { color: var(--orbit-nope, blue) }', vars)).toBe('a { color: blue }') + expect(withThemeVariables('a { color: var(--orbit-nope, rgb(1, 2, 3)) }', vars)).toBe('a { color: rgb(1, 2, 3) }') + expect(withThemeVariables('a { color: var(--page-own) }', vars)).toBe('a { color: var(--page-own) }') + }) +}) + +describe('[BO-11] JavaScript', () => { + it('runs in a function of its own whose errors go to the page’s console, named', () => { + const script = tweakScript({ name: 'My "best" ', js: 'throw new Error("x") // last line' }) + expect(() => new Function(script)).not.toThrow() + expect(script).toContain(JSON.stringify('[Tweak “My "best" ”]')) + const logged: unknown[][] = [] + const run = new Function('window', 'console', script) + run({}, { error: (...args: unknown[]) => logged.push(args) }) + expect(logged).toHaveLength(1) + expect(String(logged[0]?.[1])).toContain('Error: x') + }) +}) From b7a762f395df0de7c9cdd1ffd33bd5c1820cf181 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:11:59 +0000 Subject: [PATCH 03/22] feat(tweaks): every entry point opens the Tweak editor over the page; Tweaks apply before first paint (BO-01, BO-02, BO-18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Entry points: command bar (New Tweak…, also "customize"), the sidebar's + menu, the site controls panel, a paintbrush in the address field and the tab's menu (Tweak this Site…). Internal pages and error pages take none; the actions say why. - The editor is a small view of its own over the page's top-right corner, sized to the panel, draggable by its title and kept inside the page; Esc, ✕ and Done close it and give the page the keyboard back. A Tweak the panel made and left empty goes with it. - The page preload asks main for the site's sheets before first paint (D40); main swaps in its own insertCSS copies at document ready and keeps them in line with every edit. - e2e: tests/e2e/tweaks.spec.ts, with a frame-capture test that no frame shows the page without its Tweak. Co-Authored-By: Claude Opus 5.5 --- src/main/keys/menu-hints.ts | 2 + src/main/tabs/commands.ts | 2 + src/main/tabs/menus.ts | 3 + src/main/tweaks/editor.ts | 13 +- src/main/tweaks/index.ts | 5 +- src/main/tweaks/menu.ts | 28 + src/main/tweaks/pages.ts | 26 +- src/preload/page.ts | 3 +- src/renderer/src/shell/chrome/Address.tsx | 2 + src/renderer/src/shell/spaces/SpacePanels.tsx | 3 + src/renderer/src/tweaks/TweakButton.tsx | 40 ++ src/renderer/src/tweaks/TweakEditorApp.tsx | 43 +- src/renderer/src/tweaks/entry.tsx | 24 + src/renderer/src/tweaks/site.ts | 32 ++ src/renderer/src/tweaks/tweak-entry.css | 20 + src/renderer/src/tweaks/tweak-site.css | 51 ++ .../src/tweaks/tweaks.site-controls.tsx | 91 ++++ src/shared/tweaks.ts | 32 +- tests/e2e/tweaks-support.ts | 203 ++++++++ tests/e2e/tweaks.spec.ts | 485 ++++++++++++++++++ tests/unit/tweaks-logic.test.ts | 75 ++- 21 files changed, 1137 insertions(+), 46 deletions(-) create mode 100644 src/main/tweaks/menu.ts create mode 100644 src/renderer/src/tweaks/TweakButton.tsx create mode 100644 src/renderer/src/tweaks/entry.tsx create mode 100644 src/renderer/src/tweaks/site.ts create mode 100644 src/renderer/src/tweaks/tweak-entry.css create mode 100644 src/renderer/src/tweaks/tweak-site.css create mode 100644 src/renderer/src/tweaks/tweaks.site-controls.tsx create mode 100644 tests/e2e/tweaks-support.ts create mode 100644 tests/e2e/tweaks.spec.ts diff --git a/src/main/keys/menu-hints.ts b/src/main/keys/menu-hints.ts index 9388808b..bd8f20d9 100644 --- a/src/main/keys/menu-hints.ts +++ b/src/main/keys/menu-hints.ts @@ -28,6 +28,8 @@ export const TAB_MENU_KEYS: KeyHints = [ ['Duplicate', 'tab.duplicate'], ['Mute Tab', 'tab.toggleMute'], ['Unmute Tab', 'tab.toggleMute'], + // From the tweaks lane (src/main/tweaks/menu.ts, BO-01): no key unless the user gives it one. + ['Tweak this Site…', 'tweak.new'], ['Close Tab', 'tab.close'], [/^Close \d+ Tabs$/, 'tab.close'], ] diff --git a/src/main/tabs/commands.ts b/src/main/tabs/commands.ts index 04a2e538..378db6a2 100644 --- a/src/main/tabs/commands.ts +++ b/src/main/tabs/commands.ts @@ -29,6 +29,7 @@ import { TAB_MENU_KEYS, withKeyHints } from '../keys/menu-hints' import { printPage, savePage, viewSource } from '../pages/entry' import type { CallContext } from '../registry' import { tabSplitMenuItems } from '../split/menu' +import { tabTweakMenuItems } from '../tweaks/menu' import { TabMaintenance } from './maintenance' import { findMenuItem, folderMenu, type MenuOps, menuLabels, tabMenu } from './menus' import { makeTab, numberedTarget, steppedTarget, todayInsertIndex } from './model' @@ -211,6 +212,7 @@ export function registerTabs(ctx: MainContext): { ops: TabOps; maintenance: TabM pasteUrlInto: (folderId) => void pasteUrlAsTab(windowId, { kind: 'folder', folderId }), deleteFolder: (folderId) => void confirmDeleteFolder(windowId, folderId), splitItems: (tabIds) => tabSplitMenuItems(ctx, tabIds, windowId), + tweakItems: (tabId) => tabTweakMenuItems(ctx, tabId, windowId), }) // ---- Commands ---- diff --git a/src/main/tabs/menus.ts b/src/main/tabs/menus.ts index e529a3de..229eefe0 100644 --- a/src/main/tabs/menus.ts +++ b/src/main/tabs/menus.ts @@ -32,6 +32,8 @@ export interface MenuOps { deleteFolder(folderId: FolderId): void /** Split View items for these tabs ("Open in Split View", SV-06/SV-09), from the split lane. */ splitItems?(tabIds: TabId[]): MenuItemConstructorOptions[] + /** "Tweak this Site…" for a tab (BO-01), from the tweaks lane. */ + tweakItems?(tabId: TabId): MenuItemConstructorOptions[] } /** Folders of a Space's pinned area, depth-first, with indentation for the Move to menu. */ @@ -151,6 +153,7 @@ export function tabMenu( { label: 'Duplicate', click: () => ops.duplicate(tabId) }, ...(ops.splitItems?.([tabId]) ?? []), { label: tab.muted ? 'Unmute Tab' : 'Mute Tab', click: () => ops.setMuted(tabId, !tab.muted) }, + ...(ops.tweakItems?.(tabId) ?? []), { label: 'Move to', submenu: moveToMenu(state, [item], ops, { favoritesProfileId: section === 'favorite' ? undefined : profileId }), diff --git a/src/main/tweaks/editor.ts b/src/main/tweaks/editor.ts index 2ea91860..25939fbc 100644 --- a/src/main/tweaks/editor.ts +++ b/src/main/tweaks/editor.ts @@ -228,6 +228,7 @@ export function registerTweakEditor(ctx: MainContext): TweakEditor { if ( !tweak || !tab || + tab.error || !next.settings.tweaksEnabled || !shownTabs(next, windowId).has(tab.id) || !tweakAppliesTo(tweak, tweakHostOf(tab.url)) @@ -235,7 +236,10 @@ export function registerTweakEditor(ctx: MainContext): TweakEditor { close(windowId) continue } - if (peekShown(next, windowId) !== peekShown(prev, windowId) || next.windows[windowId] !== prev.windows[windowId]) { + if ( + peekShown(next, windowId) !== peekShown(prev, windowId) || + next.windows[windowId] !== prev.windows[windowId] + ) { place(windowId) } } @@ -275,8 +279,13 @@ export function registerTweakEditor(ctx: MainContext): TweakEditor { } const d = e.drag if (!d) return + // The panel stays where the last move put it: a release where the pointer was then may be + // reported after the view moved, at a point that never was (synthetic input). + if (phase === 'end') { + e.drag = undefined + return + } e.offset = { x: d.ox + x - d.x, y: d.oy + y - d.y } - if (phase === 'end') e.drag = undefined place(windowId) }, info: (windowId) => { diff --git a/src/main/tweaks/index.ts b/src/main/tweaks/index.ts index cbbeaa37..efa876b0 100644 --- a/src/main/tweaks/index.ts +++ b/src/main/tweaks/index.ts @@ -45,7 +45,10 @@ export function registerTweaks(ctx: MainContext): void { * The tab an entry point acts on, shown in its window so the panel can go over it; null (with a * note in the window saying why) where Tweaks can't open. */ - const target = async (asked: TabId | undefined, c: CallContext): Promise<{ windowId: WindowId; tabId: TabId; host: string } | null> => { + const target = async ( + asked: TabId | undefined, + c: CallContext, + ): Promise<{ windowId: WindowId; tabId: TabId; host: string } | null> => { const state = store.state const windowId = (asked ? windows.windowOfTab(asked) : undefined) ?? windowOf(c) if (!windowId) return null diff --git a/src/main/tweaks/menu.ts b/src/main/tweaks/menu.ts new file mode 100644 index 00000000..69f83d93 --- /dev/null +++ b/src/main/tweaks/menu.ts @@ -0,0 +1,28 @@ +import type { MenuItemConstructorOptions } from 'electron' +import type { TabId, WindowId } from '../../shared/state' +import { tweakHostOf } from '../../shared/tweaks' +import type { MainContext } from '../context' + +/** The tab menu's item (BO-01). */ +export const TAB_MENU_TWEAK = 'Tweak this Site…' + +/** + * "Tweak this Site…" in a tab's menu (src/main/tabs/menus.ts, BO-01): a new Tweak for the tab's + * site, the tab shown first. Only on a website, and while Tweaks are on (BO-16). It shows the key + * of New Tweak when the user gave it one (TAB_MENU_KEYS, KS-18). + */ +export function tabTweakMenuItems( + ctx: MainContext, + tabId: TabId, + windowId: WindowId | undefined, +): MenuItemConstructorOptions[] { + const state = ctx.store.state + const tab = state.tabs[tabId] + if (!tab || tab.error || !state.settings.tweaksEnabled || !tweakHostOf(tab.url)) return [] + return [ + { + label: TAB_MENU_TWEAK, + click: () => void ctx.commands.run('tweaks.create', { tabId }, { windowId }).catch(() => {}), + }, + ] +} diff --git a/src/main/tweaks/pages.ts b/src/main/tweaks/pages.ts index 4131002b..3de7bc22 100644 --- a/src/main/tweaks/pages.ts +++ b/src/main/tweaks/pages.ts @@ -2,12 +2,22 @@ import { type IpcMainEvent, ipcMain, type Session, type WebContents } from 'elec import { isPrivateProfileId } from '../../shared/private' import { PAGE_CHANNEL } from '../../shared/site' import type { OrbitState, ProfileId, TabId } from '../../shared/state' -import { enabledTweaksFor, type SheetOptions, type SheetPart, tweakHostOf, tweakScript, tweakSheets } from '../../shared/tweaks' +import { + enabledTweaksFor, + type SheetOptions, + type SheetPart, + tweakHostOf, + tweakScript, + tweakSheets, +} from '../../shared/tweaks' import type { MainContext } from '../context' /** The isolated world main's own scripts for Tweaks run in (never the page's world). */ export const TWEAK_WORLD = 1040 +/** Chromium's net::ERR_ABORTED: a navigation stopped or replaced, which leaves the page as it was. */ +const ERR_ABORTED = -3 + /** * Takes the page preload's Tweak sheets out of the page (they carry the marker media type). * Runs in TWEAK_WORLD: the page's own adopted sheets stay as they are. @@ -121,7 +131,9 @@ export function registerTweakPages(ctx: MainContext): TweakPages { if (record.adopted) { // Main's own sheets are in: the preload's go. record.adopted = false - await record.contents.executeJavaScriptInIsolatedWorld(TWEAK_WORLD, [{ code: RETIRE_PRELOAD_SHEETS }]).catch(() => {}) + await record.contents + .executeJavaScriptInIsolatedWorld(TWEAK_WORLD, [{ code: RETIRE_PRELOAD_SHEETS }]) + .catch(() => {}) } } @@ -180,6 +192,14 @@ export function registerTweakPages(ctx: MainContext): TweakPages { // Same document, same origin: the page keeps its sheets (BO-17: in-page navigations). if (isMainFrame && record.host && tweakHostOf(url) === record.host) record.url = url }) + // An error page took the page's place, or the page crashed: no Tweak goes there, until a page + // asks again (a stopped or replaced navigation leaves the page as it was). + contents.on('did-fail-load', (_event, code, _description, _url, isMainFrame) => { + if (isMainFrame && code !== ERR_ABORTED) record.host = null + }) + contents.on('render-process-gone', () => { + record.host = null + }) contents.once('destroyed', () => records.delete(id)) return record } @@ -244,7 +264,7 @@ export function registerTweakPages(ctx: MainContext): TweakPages { runNow: (tabId, tweakId) => { const record = recordOf(tabId) const tweak = store.state.tweaks[tweakId] - if (!record || !tweak || !tweak.js.trim() || !tweak.jsAllowed) return false + if (!record || !tweak?.js.trim() || !tweak.jsAllowed) return false if (isPrivateProfileId(profileOf(record.contents)) && !tweak.jsInPrivate) return false record.contents.mainFrame.executeJavaScript(tweakScript(tweak)).catch(() => {}) return true diff --git a/src/preload/page.ts b/src/preload/page.ts index 6be0ef2b..d1822846 100644 --- a/src/preload/page.ts +++ b/src/preload/page.ts @@ -59,7 +59,8 @@ const screenShared = (count: number) => { * shows in the engine's viewer, which no Tweak changes (BO-01). */ function applyTweakSheets(): void { - if (window.top !== window || !/^https?:$/.test(location.protocol) || document.contentType === 'application/pdf') return + if (window.top !== window || !/^https?:$/.test(location.protocol) || document.contentType === 'application/pdf') + return let sheets: unknown try { sheets = ipcRenderer.sendSync(PAGE_CHANNEL.tweakSheets) diff --git a/src/renderer/src/shell/chrome/Address.tsx b/src/renderer/src/shell/chrome/Address.tsx index 83e37d51..9bb0c126 100644 --- a/src/renderer/src/shell/chrome/Address.tsx +++ b/src/renderer/src/shell/chrome/Address.tsx @@ -6,6 +6,7 @@ import { displayHost } from '../../../../shared/url' import { invoke, runAction } from '../../api' import { useKeymap } from '../../keys/live' import { useOrbit } from '../../store' +import { TweakButton } from '../../tweaks/TweakButton' import { withShortcut } from './shortcut-label' /** How long the copy button shows its check mark. */ @@ -121,6 +122,7 @@ export function Address({ windowId }: { windowId: string }) { )} + {page && !peek && } {page && ( + ) +} diff --git a/src/renderer/src/tweaks/TweakEditorApp.tsx b/src/renderer/src/tweaks/TweakEditorApp.tsx index a27ceeda..88dee6df 100644 --- a/src/renderer/src/tweaks/TweakEditorApp.tsx +++ b/src/renderer/src/tweaks/TweakEditorApp.tsx @@ -41,9 +41,7 @@ export function TweakEditorApp() { style={paletteVars(palette) as CSSProperties} data-testid="tweak-view" > - {state?.open && tweak && ( - - )} + {state?.open && tweak && } ) } @@ -53,24 +51,36 @@ function TweakPanel({ state, tweak, intro }: { state: TweakEditorState; tweak: T const head = useRef(null) const content = useRef(null) const foot = useRef(null) - const wide = false // Main sizes the view to the panel: its natural height, whatever room the view has now. useLayoutEffect(() => { const parts = [head.current, content.current, foot.current] const report = () => { const height = parts.reduce((sum, el) => sum + (el?.offsetHeight ?? 0), 0) + 2 - void invoke('tweaks.editorLayout', { width: wide ? TWEAK_EDITOR.wideWidth : TWEAK_EDITOR.width, height }) + void invoke('tweaks.editorLayout', { width: TWEAK_EDITOR.width, height }) } report() const observer = new ResizeObserver(report) for (const el of parts) if (el) observer.observe(el) return () => observer.disconnect() - }, [wide]) + }, []) - // Each opening starts at the top, with the keyboard in the panel: Esc closes it (BO-02, KS-19). + // Each opening starts with the keyboard in the panel (KS-19), and so does the controls' first + // showing after the intro: its button is gone, and the keyboard would be nowhere. + // biome-ignore lint/correctness/useExhaustiveDependencies: on opening, and as the intro goes useEffect(() => { panel.current?.focus({ preventScroll: true }) + }, [intro]) + + // Esc anywhere in the panel closes it (BO-02), unless a field used it (a rename it cancels, say). + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key !== 'Escape' || e.defaultPrevented || e.isComposing) return + e.preventDefault() + void invoke('tweaks.closeEditor', {}) + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) }, []) const close = () => void invoke('tweaks.closeEditor', {}) @@ -84,12 +94,7 @@ function TweakPanel({ state, tweak, intro }: { state: TweakEditorState; tweak: T data-tweak={tweak.id} aria-label={`Tweak: ${tweak.name}`} tabIndex={-1} - style={{ width: wide ? TWEAK_EDITOR.wideWidth : TWEAK_EDITOR.width }} - onKeyDown={(e) => { - if (e.key !== 'Escape' || e.defaultPrevented || e.nativeEvent.isComposing) return - e.preventDefault() - close() - }} + style={{ width: TWEAK_EDITOR.width }} >
@@ -137,8 +142,8 @@ function Intro({ host }: { host: string }) {

Tweak any website

- Change how {host} looks: its colours, fonts and size, or hide what you don't need. Every - change shows at once and is saved as you go. + Change how {host} looks: its colours, fonts and size, or hide what you don't need. Every change + shows at once and is saved as you go.

Tweaks stay on this device and come back each time you visit. Turn them off any time from the site controls. @@ -157,11 +162,9 @@ function Intro({ host }: { host: string }) { function Controls({ tweak }: { tweak: Tweak }) { return ( - <> - - - - + + + ) } diff --git a/src/renderer/src/tweaks/entry.tsx b/src/renderer/src/tweaks/entry.tsx new file mode 100644 index 00000000..08784989 --- /dev/null +++ b/src/renderer/src/tweaks/entry.tsx @@ -0,0 +1,24 @@ +import { Paintbrush } from 'lucide-react' +import type { ReactNode } from 'react' +import { useTweakSite } from './site' + +/** An entry of the sidebar's + menu (src/renderer/src/shell/spaces/SpacePanels.tsx). */ +interface PlusEntry { + id: string + label: string + icon: ReactNode + disabled: boolean + run: () => void +} + +/** + * The + menu's "New Tweak…" (BO-01): for the window's shown page, greyed where it isn't a website + * Tweaks can change, and gone while Tweaks are off (BO-16). Returns a function of the entry's run. + */ +export function useTweakMenuEntry(windowId: string): (run: () => void) => PlusEntry[] { + const site = useTweakSite(windowId) + return (run) => + site.allowed + ? [{ id: 'plus-tweak', label: 'New Tweak…', icon: , disabled: !site.host, run }] + : [] +} diff --git a/src/renderer/src/tweaks/site.ts b/src/renderer/src/tweaks/site.ts new file mode 100644 index 00000000..8e28c6a6 --- /dev/null +++ b/src/renderer/src/tweaks/site.ts @@ -0,0 +1,32 @@ +import { peekShown, shownTabId } from '../../../shared/nav' +import type { OrbitState, TabId } from '../../../shared/state' +import { tweakHostOf, tweaksFor } from '../../../shared/tweaks' +import { useOrbitShallow } from '../store' + +/** What the entry points know of the Tweaks of a page's site (BO-01, BO-14, BO-16). */ +export interface TweakSite { + /** Tweaks are on as a whole (BO-16). */ + allowed: boolean + /** The site's host, where the page is a website Tweaks can change; null elsewhere. */ + host: string | null + /** The site's Tweaks, and how many of them are on. */ + total: number + on: number +} + +/** The Tweaks of a tab's site, as primitives (the caller re-renders only when one changes). */ +export function tweakSiteOf(state: OrbitState, tabId: TabId | null): TweakSite { + const tab = tabId ? state.tabs[tabId] : undefined + const host = tab && !tab.error ? tweakHostOf(tab.url) : null + const list = tweaksFor(state.tweaks, host) + let on = 0 + for (const tweak of list) if (tweak.enabled) on++ + return { allowed: state.settings.tweaksEnabled, host, total: list.length, on } +} + +/** The Tweaks of the site a window shows; none while a Peek shows (the panel can't go over one). */ +export function useTweakSite(windowId: string): TweakSite { + return useOrbitShallow((s) => + peekShown(s, windowId) ? tweakSiteOf(s, null) : tweakSiteOf(s, shownTabId(s, windowId)), + ) +} diff --git a/src/renderer/src/tweaks/tweak-entry.css b/src/renderer/src/tweaks/tweak-entry.css new file mode 100644 index 00000000..c9a25ab7 --- /dev/null +++ b/src/renderer/src/tweaks/tweak-entry.css @@ -0,0 +1,20 @@ +/* + * The paintbrush in the address field (BO-01, BO-14): see TweakButton.tsx. It takes the field's + * button look (chrome.css, .address__button). + */ + +/* No Tweak for the site yet: it shows with the copy button, while the pointer is over the field. */ +.address__tweak.is-none { + opacity: 0; +} + +.address:hover .address__tweak.is-none, +.address:focus-within .address__tweak.is-none { + opacity: 1; +} + +/* A Tweak is on: the brush takes the Space's accent. */ +.address__tweak.is-on, +.address__tweak.is-on:hover { + color: var(--accent); +} diff --git a/src/renderer/src/tweaks/tweak-site.css b/src/renderer/src/tweaks/tweak-site.css new file mode 100644 index 00000000..61b4daac --- /dev/null +++ b/src/renderer/src/tweaks/tweak-site.css @@ -0,0 +1,51 @@ +/* The site's Tweaks in the site controls panel (BO-14): see tweaks.site-controls.tsx. */ + +.tweak-site-row { + padding-right: 4px; +} + +.tweak-site-row__toggle { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 9px; + min-height: 30px; + margin-left: -4px; + padding: 0 4px; + border-radius: 7px; + text-align: left; +} + +.tweak-site-row__toggle:hover { + background: var(--site-fill); +} + +.tweak-site-row__brush { + flex: none; + color: var(--text-muted, rgba(28, 28, 31, 0.56)); + opacity: 0.7; +} + +/* A Tweak that is on: its brush in the Space's accent. */ +.tweak-site-row.is-on .tweak-site-row__brush { + color: var(--accent, #5865d8); + opacity: 1; +} + +.tweak-site-row__name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tweak-site-row__badge { + flex: none; + padding: 1px 5px; + border-radius: 5px; + background: var(--site-fill); + color: var(--text-muted, rgba(28, 28, 31, 0.56)); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.03em; +} diff --git a/src/renderer/src/tweaks/tweaks.site-controls.tsx b/src/renderer/src/tweaks/tweaks.site-controls.tsx new file mode 100644 index 00000000..33c421f3 --- /dev/null +++ b/src/renderer/src/tweaks/tweaks.site-controls.tsx @@ -0,0 +1,91 @@ +import { ChevronRight, Paintbrush, Plus } from 'lucide-react' +import { tweakHostOf, tweaksFor } from '../../../shared/tweaks' +import { invoke } from '../api' +import type { SiteControlsSection, SiteSectionProps } from '../overlay/site/registry' +import { useOrbit, useOrbitShallow } from '../store' +import './tweak-site.css' + +/** + * The site's Tweaks in the site controls panel (BO-14, BO-01): a row each, whose name turns it on + * or off at once and whose › opens it in the editor, and "New Tweak…" for another. + */ +function TweakRows({ tabId, info, close }: SiteSectionProps) { + const host = info.web ? tweakHostOf(info.url) : null + const allowed = useOrbit((s) => s.settings.tweaksEnabled) + const failed = useOrbit((s) => !!s.tabs[tabId]?.error) + const list = useOrbitShallow((s) => tweaksFor(s.tweaks, host)) + if (!host || failed) return null + const edit = async (id: string) => { + await close() + await invoke('tweaks.edit', { id, tabId }) + } + const create = async () => { + await close() + await invoke('tweaks.create', { tabId }) + } + return ( +

+

Tweaks

+ {!allowed ? ( +

+ Tweaks are turned off for every site in Settings. +

+ ) : ( +
    + {list.map((tweak) => ( +
  • + + {tweak.js.trim() && ( + + JS + + )} + +
  • + ))} +
  • + +
  • +
+ )} +
+ ) +} + +export const section: SiteControlsSection = { id: 'tweaks', order: 45, component: TweakRows } diff --git a/src/shared/tweaks.ts b/src/shared/tweaks.ts index a0cda0c1..bfcea112 100644 --- a/src/shared/tweaks.ts +++ b/src/shared/tweaks.ts @@ -14,8 +14,8 @@ * - JavaScript (BO-11): the code main runs in the page, with errors going to the page's console. */ import { TWEAK_SHEET_MEDIA } from './site' -import { hexToHsl } from './theme' import type { Settings, Tweak, TweakHidden, TweakStyle } from './state' +import { hexToHsl } from './theme' // ---- Model ---------------------------------------------------------------- @@ -659,7 +659,8 @@ export function mediaFilter(chain: FilterStep[]): string | null { if (!inverse) return null const { m, t } = inverse const r = (n: number) => String(round(n, 4)) - const row = (k: number) => `${r(m[k * 3] as number)} ${r(m[k * 3 + 1] as number)} ${r(m[k * 3 + 2] as number)} 0 ${r(t[k] as number)}` + const row = (k: number) => + `${r(m[k * 3] as number)} ${r(m[k * 3 + 1] as number)} ${r(m[k * 3 + 2] as number)} 0 ${r(t[k] as number)}` const values = `${row(0)} ${row(1)} ${row(2)} 0 0 0 1 0` const svg = `` return `url("data:image/svg+xml,${encodeURIComponent(svg)}#m")` @@ -764,7 +765,16 @@ export function hiddenCss(hidden: readonly TweakHidden[], skip?: string): string // ---- Custom CSS (BO-11) ----------------------------------------------------- /** At-rules whose blocks hold more rules. */ -const GROUP_RULES = new Set(['media', 'supports', 'layer', 'container', 'document', '-moz-document', 'scope', 'starting-style']) +const GROUP_RULES = new Set([ + 'media', + 'supports', + 'layer', + 'container', + 'document', + '-moz-document', + 'scope', + 'starting-style', +]) /** At-rules whose blocks hold keyframes: their declarations can't be important. */ const KEYFRAMES = /^(-webkit-|-moz-|-o-)?keyframes$/ @@ -778,7 +788,8 @@ function stringEnd(css: string, i: number): { end: number; closed: boolean } { const c = css[j] if (c === '\\') j += 2 else if (c === quote) return { end: j + 1, closed: true } - else if (c === '\n') return { end: j, closed: true } // CSS ends a string at a line break + else if (c === '\n') + return { end: j, closed: true } // CSS ends a string at a line break else j++ } return { end: css.length, closed: false } @@ -956,11 +967,14 @@ export type ThemeVariables = Record /** `var(--orbit-…)` replaced by the colour (or its fallback), so the page never sees the variables. */ export function withThemeVariables(css: string, vars: ThemeVariables | undefined): string { if (!css.includes('--orbit-')) return css - return css.replace(/var\(\s*(--orbit-[\w-]+)\s*(?:,\s*([^()]*(?:\([^()]*\)[^()]*)*))?\)/g, (all, name: string, fallback?: string) => { - const value = vars?.[name] - if (value) return value - return fallback !== undefined ? fallback.trim() : all - }) + return css.replace( + /var\(\s*(--orbit-[\w-]+)\s*(?:,\s*([^()]*(?:\([^()]*\)[^()]*)*))?\)/g, + (all, name: string, fallback?: string) => { + const value = vars?.[name] + if (value) return value + return fallback !== undefined ? fallback.trim() : all + }, + ) } // ---- A page's stylesheets --------------------------------------------------- diff --git a/tests/e2e/tweaks-support.ts b/tests/e2e/tweaks-support.ts new file mode 100644 index 00000000..38c4338d --- /dev/null +++ b/tests/e2e/tweaks-support.ts @@ -0,0 +1,203 @@ +/** + * Helpers for the Tweaks e2e specs (tweaks*.spec.ts, BO-*): fixture pages, the editor panel's view, + * what main knows of a page's Tweak sheets, and the frames a tab's page paints. Builds on helpers.ts. + */ +import { createServer, type Server } from 'node:http' +import type { ElectronApplication, Page } from '@playwright/test' +import { expect } from '@playwright/test' +import type { TweakEditorInfo } from '../../src/main/tweaks/editor' +import type { TweakTestHooks } from '../../src/main/tweaks/index' +import type { Tweak } from '../../src/shared/state' +import { listenInRange, state } from './helpers' + +export interface TweakFixtures { + port: number + /** A fixture URL on `host` (localhost by default). */ + url: (path: string, host?: string) => string + /** Requests the server saw (host, method and path), newest last. */ + seen: () => string[] + close: () => Promise +} + +/** + * Records, in the page's first animation frame (just before its first paint), what that frame + * shows: the body's background and the root's zoom. `window.__first` holds it. + */ +const FIRST_FRAME = `` + +const head = (title: string, bg: string) => ` +${title} + +` + +/** The serial number of each load of a page, by path: a `/slow` page's block changes colour with it. */ +const BLOCK_COLORS = ['#1f5fd6', '#18a058', '#c2410c', '#7c3aed'] + +/** + * - `/page/?bg=…`: a plain page: a heading, a link to `/page/-next` (`#next`), a + * button that counts its clicks (`#count`, `window.__clicks`), a text field (`#field`) and a tall + * area. `window.__first` is what its first frame showed (FIRST_FRAME). + * - `/slow/?bg=…&ms=…`: sends its heading and a coloured block (`#block`, a new colour on each + * load) at once, and the rest of the page `ms` later (600 by default): it paints before the + * document is ready, as a page from a slow server does. + */ +export async function startTweakFixtures(base = 3370): Promise { + const seen: string[] = [] + const loads = new Map() + const server: Server = createServer((req, res) => { + const u = new URL(req.url ?? '/', 'http://localhost') + seen.push(`${req.headers.host ?? ''} ${req.method} ${u.pathname}${u.search}`) + const name = decodeURIComponent(u.pathname.split('/').filter(Boolean).pop() ?? 'index') + const bg = u.searchParams.get('bg') ?? '#ffffff' + const ok = { 'content-type': 'text/html; charset=utf-8' } + if (u.pathname.startsWith('/page/')) { + res.writeHead(200, ok) + res.end( + `${head(name, bg)}${FIRST_FRAME}

${name}

+

+

+

+
`, + ) + return + } + if (u.pathname.startsWith('/slow/')) { + const n = loads.get(u.pathname) ?? 0 + loads.set(u.pathname, n + 1) + const ms = Number(u.searchParams.get('ms') ?? 600) + res.writeHead(200, ok) + res.write( + `${head(name, bg)}${FIRST_FRAME}

${name}

+
+
`, + ) + const timer = setTimeout(() => res.end('

The end

'), ms) + res.on('close', () => clearTimeout(timer)) + return + } + res.writeHead(404, { 'content-type': 'text/plain' }) + res.end('not found') + }) + const port = await listenInRange(server, base) + return { + port, + url: (path, host = 'localhost') => `http://${host}:${port}${path}`, + seen: () => [...seen], + close: () => + new Promise((resolve) => { + server.closeAllConnections() + server.close(() => resolve()) + }), + } +} + +/** The colours of the `/slow` page's block, by load. */ +export const SLOW_BLOCKS = BLOCK_COLORS + +type Hooks = { __orbitTweaks: TweakTestHooks } + +/** A window's editor panel, as main sees it (BO-02). */ +export function editorInfo(app: ElectronApplication, windowId?: string): Promise { + return app.evaluate((_e, w) => (globalThis as unknown as Hooks).__orbitTweaks.editor(w), windowId) +} + +/** What main has put in a tab's page (BO-18): its host, its sheets by part, and whether the preload's are in. */ +export function pageTweaks(app: ElectronApplication, tabId: string): Promise> { + return app.evaluate((_e, id) => (globalThis as unknown as Hooks).__orbitTweaks.page(id), tabId) +} + +/** Every Tweak, oldest first. */ +export async function tweakList(app: ElectronApplication): Promise { + return Object.values((await state(app)).tweaks).sort((a, b) => a.createdAt - b.createdAt) +} + +/** The editor panel's view (view=tweak): its page exists once the panel first opened in a window. */ +export async function editorPage(app: ElectronApplication): Promise { + const deadline = Date.now() + 20_000 + while (Date.now() < deadline) { + const found = app.windows().find((p) => p.url().includes('view=tweak')) + if (found) return found + await new Promise((r) => setTimeout(r, 50)) + } + throw new Error('No Tweak editor view') +} + +/** Waits until the editor panel is in its window, sized and with its page laid out. */ +export async function waitEditorShown(app: ElectronApplication): Promise { + await expect.poll(async () => (await editorInfo(app)).placed, { timeout: 10_000 }).toBe(true) + return editorInfo(app) +} + +/** What a page's first frame showed (FIRST_FRAME). */ +export function firstFrame(page: Page): Promise<{ bg: string; zoom: string; font: string } | undefined> { + return page.evaluate(() => (window as unknown as { __first?: { bg: string; zoom: string; font: string } }).__first) +} + +/** A sampled point's colour in one painted frame, and when (ms since the recording started). */ +export interface FrameSample { + t: number + /** `#rrggbb` at each point asked for, in order. */ + colors: string[] +} + +/** + * Starts recording what a tab's page paints: every frame its compositor sends (Electron's frame + * subscription), sampled at points given as fractions of the page's size. `stop()` ends it. + */ +export async function recordFrames( + app: ElectronApplication, + tabId: string, + points: { x: number; y: number }[], +): Promise<{ stop: () => Promise }> { + await app.evaluate( + ({ webContents }, [id, at]) => { + type G = { + __orbit: { ctx: { tabs: { contents: (id: string) => Electron.WebContents | undefined } } } + __tweakFrames?: { t: number; colors: string[] }[] + } + const g = globalThis as unknown as G + const contents = g.__orbit.ctx.tabs.contents(id) ?? webContents.getAllWebContents()[0] + if (!contents) throw new Error('no page') + const frames: { t: number; colors: string[] }[] = [] + g.__tweakFrames = frames + const t0 = Date.now() + const hex = (n: number) => n.toString(16).padStart(2, '0') + contents.beginFrameSubscription(false, (image) => { + const size = image.getSize() + const colors = at.map((p) => { + const x = Math.min(size.width - 1, Math.max(0, Math.round(p.x * size.width))) + const y = Math.min(size.height - 1, Math.max(0, Math.round(p.y * size.height))) + const px = image.crop({ x, y, width: 1, height: 1 }).toBitmap() + // BGRA + return `#${hex(px[2] ?? 0)}${hex(px[1] ?? 0)}${hex(px[0] ?? 0)}` + }) + frames.push({ t: Date.now() - t0, colors }) + }) + }, + [tabId, points] as const, + ) + return { + stop: () => + app.evaluate((_e, id) => { + type G = { + __orbit: { ctx: { tabs: { contents: (id: string) => Electron.WebContents | undefined } } } + __tweakFrames?: { t: number; colors: string[] }[] + } + const g = globalThis as unknown as G + g.__orbit.ctx.tabs.contents(id)?.endFrameSubscription() + const frames = g.__tweakFrames ?? [] + g.__tweakFrames = undefined + return frames + }, tabId), + } +} + +/** Whether two `#rrggbb` colours are within `tolerance` on every channel. */ +export function near(a: string, b: string, tolerance = 6): boolean { + const ch = (s: string, i: number) => Number.parseInt(s.slice(1 + 2 * i, 3 + 2 * i), 16) + return [0, 1, 2].every((i) => Math.abs(ch(a, i) - ch(b, i)) <= tolerance) +} diff --git a/tests/e2e/tweaks.spec.ts b/tests/e2e/tweaks.spec.ts new file mode 100644 index 00000000..938e1ef2 --- /dev/null +++ b/tests/e2e/tweaks.spec.ts @@ -0,0 +1,485 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { type ElectronApplication, expect, type Page, test } from '@playwright/test' +import { TWEAK_EDITOR } from '../../src/shared/commands/tweaks' +import { isMac, launchOrbit, MOD, type Orbit, run, state, tabPage, tabView, viewPage } from './helpers' +import { clickInPage, focusedUrl, pressInPage } from './nav-support' +import { remoteRequests } from './settings-support' +import { act, cmd, newTab, tabsHook, waitTab } from './tabs-support' +import { + editorInfo, + editorPage, + firstFrame, + near, + pageTweaks, + recordFrames, + SLOW_BLOCKS, + startTweakFixtures, + type TweakFixtures, + tweakList, + waitEditorShown, +} from './tweaks-support' + +/** Tweaks (BO-*): per-site restyling, kept on this device. Feature IDs lead each title. */ + +let fx: TweakFixtures +let orbit: Orbit | undefined + +test.beforeAll(async () => { + fx = await startTweakFixtures() +}) +test.afterAll(async () => { + await fx.close() +}) +test.afterEach(async () => { + await orbit?.app.close() + orbit = undefined +}) + +/** A tab on fixture page `path` (on `host`), loaded. */ +async function openPage(app: ElectronApplication, path: string, host?: string): Promise<{ tabId: string; page: Page }> { + const tabId = await newTab(app, fx.url(path, host)) + const part = path.split('?')[0] as string + await waitTab(app, tabId, part) + return { tabId, page: await tabPage(app, part) } +} + +/** The editor's state in main, polled until it is open on a Tweak (and placed), or closed. */ +async function waitEditor(app: ElectronApplication, open: boolean): Promise { + if (open) await waitEditorShown(app) + else await expect.poll(async () => (await editorInfo(app)).open).toBe(false) +} + +/** The Tweak the open editor shows. */ +async function edited(app: ElectronApplication) { + const info = await editorInfo(app) + const tweak = info.tweakId ? (await state(app)).tweaks[info.tweakId] : undefined + if (!tweak) throw new Error('the editor shows no Tweak') + return tweak +} + +/** The page's root zoom now (BO-08's size is a zoom on the root). */ +const zoomOf = (page: Page) => page.evaluate(() => getComputedStyle(document.documentElement).zoom) + +/** Marks a page's document, to tell whether it was replaced (a reload or a navigation). */ +const mark = (page: Page) => page.evaluate(() => Object.assign(window, { __mark: 1 })) +const marked = (page: Page) => + page.evaluate(() => (window as unknown as { __mark?: number }).__mark === 1).catch(() => false) + +/** Waits until a page's document is a new one that has painted (its first frame was noted). */ +async function newDocument(page: Page): Promise { + await expect + .poll(async () => !(await marked(page)) && !!(await firstFrame(page).catch(() => undefined)), { timeout: 15_000 }) + .toBe(true) +} + +test('[BO-01][BO-21] every entry point makes a Tweak for the shown site and opens the editor on it, named after the site; the intro shows once; nothing reloads', async () => { + orbit = await launchOrbit() + const { app, shell } = orbit + const { tabId, page } = await openPage(app, '/page/Alpha') + await mark(page) + // 1. The command bar: "customize" finds New Tweak…. + await shell.keyboard.press(`${MOD}+T`) + const overlay = await viewPage(app, 'overlay') + const input = overlay.getByTestId('command-bar').getByTestId('command-input') + await expect(input).toBeFocused() + await input.fill('customize') + const row = overlay.getByTestId('command-bar').getByTestId('command-result').filter({ hasText: 'New Tweak…' }) + await expect(row.first()).toBeVisible() + // No key by default (BO-01): the row shows none. + await expect(row.first().getByTestId('command-shortcut')).toHaveCount(0) + await row.first().click() + await waitEditor(app, true) + const first = await edited(app) + expect(first).toMatchObject({ host: 'localhost', name: 'localhost', enabled: true }) + expect((await editorInfo(app)).tabId).toBe(tabId) + // The panel has the keyboard; the first time, it shows the intro, once. + await expect.poll(async () => (await editorInfo(app)).focused).toBe(true) + const view = await editorPage(app) + await expect(view.getByTestId('tweak-intro')).toBeVisible() + await expect(view.getByTestId('tweak-intro')).toContainText('localhost') + await view.getByTestId('tweak-intro-start').click() + await expect(view.getByTestId('tweak-intro')).toHaveCount(0) + await expect(view.getByTestId('tweak-size')).toHaveText('100%') + await expect.poll(async () => (await state(app)).settings.tweakIntroSeen).toBe(true) + // Left as it was made (nothing changed, the site's name), it goes as the panel closes. + await pressInPage(app, 'view=tweak', 'Escape') + await waitEditor(app, false) + expect(await tweakList(app)).toEqual([]) + + // 2. The sidebar's + menu. + await shell.getByTestId('sidebar-plus').click() + await overlay.getByTestId('menu-plus-tweak').click() + await waitEditor(app, true) + expect((await edited(app)).host).toBe('localhost') + await expect(view.getByTestId('tweak-intro')).toHaveCount(0) + await expect.poll(async () => (await editorInfo(app)).focused).toBe(true) + await view.getByTestId('tweak-close').click() + await waitEditor(app, false) + + // 3. The site controls panel. + await shell.getByTestId('site-controls-button').click() + await overlay.getByTestId('site-controls').getByTestId('site-tweak-new').click() + await waitEditor(app, true) + expect((await edited(app)).host).toBe('localhost') + await view.getByTestId('tweak-done').click() + await waitEditor(app, false) + expect(await tweakList(app)).toEqual([]) + + // 4. The paintbrush in the address field, shown with the field's other buttons. + await shell.getByTestId('address').hover() + const brush = shell.getByTestId('tweak-button') + await expect(brush).toHaveAttribute('data-state', 'none') + await expect(brush).toBeVisible() + await brush.click() + await waitEditor(app, true) + // A change keeps it: the page shows it at once. + await view.getByTestId('tweak-size-up').click() + await expect.poll(() => zoomOf(page)).toBe('1.1') + const kept = await edited(app) + expect(kept.style.size).toBe(110) + await view.getByTestId('tweak-done').click() + await waitEditor(app, false) + expect((await tweakList(app)).map((t) => t.id)).toEqual([kept.id]) + await expect(brush).toHaveAttribute('data-state', 'on') + + // 5. The tab's menu: a second Tweak for the same site, beside the first. + await cmd(app, 'tabs.contextMenu', { id: tabId }) + expect(await tabsHook(app, 'lastMenu')).toContain('Tweak this Site…') + expect(await tabsHook(app, 'clickMenu', 'Tweak this Site…')).toBe(true) + await waitEditor(app, true) + const second = await edited(app) + expect(second.id).not.toBe(kept.id) + expect((await tweakList(app)).map((t) => t.host)).toEqual(['localhost', 'localhost']) + await pressInPage(app, 'view=tweak', 'Escape') + await waitEditor(app, false) + expect((await tweakList(app)).map((t) => t.id)).toEqual([kept.id]) + + // Making Tweaks neither reloaded nor navigated the page. + expect(await marked(page)).toBe(true) + expect((await state(app)).tabs[tabId]?.url).toBe(fx.url('/page/Alpha')) +}) + +test('[BO-01][KS-18] an internal page takes no Tweak: the entry points are greyed or gone and the action says why; a key given to New Tweak… shows in the tab’s menu', async () => { + orbit = await launchOrbit() + const { app, shell } = orbit + const { tabId } = await openPage(app, '/page/Bravo') + expect(await run(app, 'keys.bind', { actionId: 'tweak.new', accel: `${isMac ? 'Cmd' : 'Ctrl'}+Shift+Y` })).toEqual({ + ok: true, + }) + await cmd(app, 'tabs.contextMenu', { id: tabId }) + const hints = await app.evaluate(() => + (globalThis as unknown as { __orbitKeys: { lastMenuHints: () => string[] } }).__orbitKeys.lastMenuHints(), + ) + expect(hints).toContain(`Tweak this Site…=${isMac ? 'Command' : 'Control'}+Shift+Y`) + + const history = await newTab(app, 'orbit://history/') + await expect.poll(async () => (await state(app)).tabs[history]?.url).toBe('orbit://history/') + await act(app, 'tweak.new') + await expect(shell.getByTestId('toast').filter({ hasText: 'Tweaks work on websites only' })).toBeVisible() + expect((await editorInfo(app)).open).toBe(false) + expect(await tweakList(app)).toEqual([]) + await expect(shell.getByTestId('tweak-button')).toHaveCount(0) + await cmd(app, 'tabs.contextMenu', { id: history }) + expect(await tabsHook(app, 'lastMenu')).not.toContain('Tweak this Site…') + await shell.getByTestId('sidebar-plus').click() + const overlay = await viewPage(app, 'overlay') + await expect(overlay.getByTestId('menu-plus-tweak')).toBeDisabled() + await overlay.keyboard.press('Escape') + // A Tweak made for the site never reaches the internal page (D40). + expect(await pageTweaks(app, history)).toBeNull() +}) + +test('[BO-02][KS-19] the editor is a small view of its own over the page’s top-right corner, sized to the panel; the page stays live; Esc, ✕ and Done close it, the page gets the keyboard back, and it reopens as it was', async () => { + orbit = await launchOrbit() + const { app } = orbit + await run(app, 'tweaks.dismissIntro', {}) + const { tabId, page } = await openPage(app, '/page/Charlie') + await act(app, 'tweak.new') + const info = await waitEditorShown(app) + const view = await editorPage(app) + await expect(view.getByTestId('tweak-size')).toBeVisible() + + // Its own view, as wide as the panel and its shadow's margin, at the page's top-right corner. + const pageRect = (await tabView(app, tabId))?.bounds + if (!pageRect || !info.bounds) throw new Error('no bounds') + const { margin, inset, width } = TWEAK_EDITOR + expect(info.bounds.width).toBe(width + 2 * margin) + expect(info.bounds.x + info.bounds.width).toBe(pageRect.x + pageRect.width - inset + margin) + expect(info.bounds.y).toBe(pageRect.y + inset - margin) + // As tall as the panel: no more of the page is covered than the panel needs. + const panelHeight = await view.getByTestId('tweak-panel').evaluate((el) => el.getBoundingClientRect().height) + expect(Math.abs(info.bounds.height - (panelHeight + 2 * margin))).toBeLessThanOrEqual(2) + expect(info.bounds.height).toBeLessThan(pageRect.height / 2) + await expect.poll(async () => (await editorInfo(app)).focused).toBe(true) + + // Every change shows at once, and is saved as it is made. + await view.getByTestId('tweak-size-up').click() + await expect(view.getByTestId('tweak-size')).toHaveText('110%') + await expect.poll(() => zoomOf(page)).toBe('1.1') + const tweakId = (await editorInfo(app)).tweakId as string + expect((await state(app)).tweaks[tweakId]?.style.size).toBe(110) + + // The page around it stays live: it takes a click, and the panel stays. + await clickInPage(app, '/page/Charlie', '#count') + await expect.poll(() => page.evaluate(() => (window as unknown as { __clicks?: number }).__clicks)).toBe(1) + expect((await editorInfo(app)).open).toBe(true) + + // Esc in the panel closes it; the page has the keyboard again. + await pressInPage(app, 'view=tweak', 'Escape') + await waitEditor(app, false) + expect((await editorInfo(app)).placed).toBe(false) + await expect.poll(() => focusedUrl(app)).toContain('/page/Charlie') + // Every edit was kept. + expect((await state(app)).tweaks[tweakId]?.style.size).toBe(110) + expect(await zoomOf(page)).toBe('1.1') + + // Edit Tweak (the site's one Tweak) reopens it with its values; ✕ closes it. + await act(app, 'tweak.edit') + await waitEditorShown(app) + expect((await editorInfo(app)).tweakId).toBe(tweakId) + await expect(view.getByTestId('tweak-size')).toHaveText('110%') + await view.getByTestId('tweak-close').click() + await waitEditor(app, false) + + // The site controls panel's › reopens it too; Done closes it. + await orbit.shell.getByTestId('site-controls-button').click() + const overlay = await viewPage(app, 'overlay') + const tweakRow = overlay.getByTestId('site-controls').getByTestId('site-tweak') + await expect(tweakRow).toHaveCount(1) + await tweakRow.getByTestId('site-tweak-edit').click() + await waitEditorShown(app) + expect((await editorInfo(app)).tweakId).toBe(tweakId) + await view.getByTestId('tweak-done').click() + await waitEditor(app, false) + await expect.poll(() => focusedUrl(app)).toContain('/page/Charlie') +}) + +test('[BO-02] the editor follows its page: another page of the site keeps it open, another site or tab closes it; dragged by its title it moves, kept inside the page', async () => { + orbit = await launchOrbit() + const { app } = orbit + await run(app, 'tweaks.dismissIntro', {}) + const { tabId, page } = await openPage(app, '/page/Delta') + await act(app, 'tweak.new') + await waitEditorShown(app) + const view = await editorPage(app) + await view.getByTestId('tweak-size-up').click() + await expect.poll(() => zoomOf(page)).toBe('1.1') + + // Another page of the same site: the panel stays, and the Tweak is on that page too (BO-01). + await mark(page) + await clickInPage(app, '/page/Delta', '#next') + await expect.poll(async () => (await state(app)).tabs[tabId]?.url).toContain('/page/Delta-next') + await waitTab(app, tabId, '/page/Delta-next') + await newDocument(page) + expect((await firstFrame(page))?.zoom).toBe('1.1') + expect((await editorInfo(app)).open).toBe(true) + + // Dragged by its title, it moves with the pointer. + const before = (await editorInfo(app)).bounds + const head = view.locator('.tweak-panel__head') + const box = await head.boundingBox() + if (!before || !box) throw new Error('no bounds') + const start = { x: box.x + 40, y: box.y + box.height / 2 } + await view.mouse.move(start.x, start.y) + await view.mouse.down() + await view.mouse.move(start.x - 120, start.y + 70) + await view.mouse.up() + await expect + .poll(async () => { + const b = (await editorInfo(app)).bounds + return b ? { dx: b.x - before.x, dy: b.y - before.y } : null + }) + .toEqual({ dx: -120, dy: 70 }) + // Never out of the page: a drag far up and right stops at its corner. + const pageRect = (await tabView(app, tabId))?.bounds + if (!pageRect) throw new Error('no page') + const moved = (await editorInfo(app)).bounds as NonNullable + const box2 = await head.boundingBox() + if (!box2) throw new Error('no head') + await view.mouse.move(box2.x + 40, box2.y + box2.height / 2) + await view.mouse.down() + await view.mouse.move(box2.x + 40 + 2000, box2.y + box2.height / 2 - 2000) + await view.mouse.up() + await expect + .poll(async () => (await editorInfo(app)).bounds) + .toEqual({ + x: pageRect.x + pageRect.width - TWEAK_EDITOR.width - TWEAK_EDITOR.margin, + y: pageRect.y - TWEAK_EDITOR.margin, + width: moved.width, + height: moved.height, + }) + + // Another site in the tab: the panel closes; the Tweak stays with its site. + await cmd(app, 'tabs.navigate', { tabId, url: fx.url('/page/Elsewhere', '127.0.0.1') }) + await waitEditor(app, false) + await waitTab(app, tabId, '/page/Elsewhere') + expect(await zoomOf(page)).toBe('1') + expect((await tweakList(app)).map((t) => t.style.size)).toEqual([110]) + + // Back on the site, reopened: then another tab closes it. + await cmd(app, 'tabs.back', { tabId }) + await waitTab(app, tabId, '/page/Delta-next') + await act(app, 'tweak.edit') + await waitEditorShown(app) + await openPage(app, '/page/Echo', '127.0.0.1') + await waitEditor(app, false) +}) + +test('[BO-18] a Tweak is in the page from its first frame: a hard reload and a first visit of a page that paints before it is ready never show it without the Tweak', async () => { + orbit = await launchOrbit() + const { app } = orbit + const slow = '/slow/Dark?bg=%23ffffff&ms=900' + const { tabId, page } = await openPage(app, slow) + await expect.poll(() => page.evaluate(() => !!document.getElementById('end'))).toBe(true) + const id = (await run(app, 'tweaks.create', { tabId })) as string + await run(app, 'tweaks.update', { id, patch: { css: 'body { background: #202020 }' } }) + await run(app, 'tweaks.closeEditor', {}) + await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).backgroundColor)).toBe('rgb(32, 32, 32)') + + // Where to look: the page's background below everything, and the block that changes colour with + // each load (it tells the new document's frames from the old one's). + const points = await page.evaluate(() => { + const r = (document.getElementById('block') as HTMLElement).getBoundingClientRect() + return [ + { x: 0.5, y: 0.85 }, + { x: (r.x + r.width / 2) / innerWidth, y: (r.y + r.height / 2) / innerHeight }, + ] + }) + const check = async (load: number, action: () => Promise) => { + const recording = await recordFrames(app, tabId, points) + await action() + await expect + .poll(() => page.evaluate(() => document.getElementById('block')?.dataset.load).catch(() => null)) + .toBe(String(load)) + await expect.poll(() => page.evaluate(() => !!document.getElementById('end')).catch(() => false)).toBe(true) + const frames = await recording.stop() + const fresh = frames.filter((f) => near(f.colors[1] as string, SLOW_BLOCKS[load] as string)) + const whileLoading = fresh.filter((f) => f.t < frames[frames.length - 1]!.t) + // The new document painted while it was still loading… + expect(fresh.length, JSON.stringify(frames)).toBeGreaterThan(0) + expect(whileLoading.length, JSON.stringify(frames)).toBeGreaterThan(0) + // …and every frame of it has the Tweak's background, never the page's own white. + expect( + fresh.filter((f) => !near(f.colors[0] as string, '#202020')), + JSON.stringify(frames), + ).toEqual([]) + expect((await firstFrame(page))?.bg).toBe('rgb(32, 32, 32)') + } + // A hard reload, as the spec's own check does. + await check(1, () => cmd(app, 'tabs.reload', { tabId, ignoreCache: true })) + // A first visit from another site (a new renderer process). + await cmd(app, 'tabs.navigate', { tabId, url: fx.url('/page/Before', '127.0.0.1') }) + await waitTab(app, tabId, '/page/Before') + await check(2, () => cmd(app, 'tabs.navigate', { tabId, url: fx.url(slow) })) + // Main's own copy replaced the page preload's once the document was ready. + await expect.poll(async () => (await pageTweaks(app, tabId))?.adopted).toBe(false) + expect(await page.evaluate(() => document.adoptedStyleSheets.length)).toBe(0) +}) + +test('[BO-18][BO-01] a Tweak survives reloads, other pages of its site, back and forward, a closed tab reopened, a sleeping tab woken and a restart', async () => { + orbit = await launchOrbit() + const { app, userData } = orbit + const { tabId, page } = await openPage(app, '/page/Keep') + const id = (await run(app, 'tweaks.create', { tabId })) as string + await run(app, 'tweaks.update', { id, patch: { style: { size: 125 } } }) + await run(app, 'tweaks.closeEditor', {}) + await expect.poll(() => zoomOf(page)).toBe('1.25') + + const firstZoom = async (p: Page) => (await firstFrame(p))?.zoom + // A reload. + await mark(page) + await cmd(app, 'tabs.reload', { tabId }) + await newDocument(page) + expect(await firstZoom(page)).toBe('1.25') + // Another site, then back and forward. + await mark(page) + await cmd(app, 'tabs.navigate', { tabId, url: fx.url('/page/Other', '127.0.0.1') }) + await newDocument(page) + expect(await firstZoom(page)).toBe('1') + await mark(page) + await cmd(app, 'tabs.back', { tabId }) + await newDocument(page) + expect(page.url()).toContain('/page/Keep') + expect(await firstZoom(page)).toBe('1.25') + await mark(page) + await cmd(app, 'tabs.forward', { tabId }) + await newDocument(page) + expect(await firstZoom(page)).toBe('1') + await mark(page) + await cmd(app, 'tabs.back', { tabId }) + await newDocument(page) + + // Closed and reopened. + await cmd(app, 'tabs.close', { tabId }) + await expect.poll(async () => (await state(app)).tabs[tabId]).toBeUndefined() + const reopened = (await cmd(app, 'tabs.reopenClosed', {})) as string + await waitTab(app, reopened, '/page/Keep') + const again = await tabPage(app, '/page/Keep') + await expect.poll(() => firstZoom(again)).toBe('1.25') + + // Asleep in the background, then woken. + const other = await openPage(app, '/page/Front', '127.0.0.1') + const after = (await state(app)).settings.sleepAfterMs + const slept = await tabsHook<{ slept: string[] }>(app, 'maintenance', Date.now() + after + 60_000) + expect(slept.slept).toContain(reopened) + await cmd(app, 'tabs.activate', { tabId: reopened }) + await waitTab(app, reopened, '/page/Keep') + await expect.poll(async () => firstZoom(await tabPage(app, '/page/Keep'))).toBe('1.25') + expect(other.tabId).not.toBe(reopened) + + // A restart: the Tweak and the page come back, the page with it from its first frame. + await cmd(app, 'tabs.activate', { tabId: reopened }) + await app.close() + orbit = await launchOrbit({ userData }) + const restored = orbit.app + const tweaks = await tweakList(restored) + expect(tweaks.map((t) => [t.host, t.style.size])).toEqual([['localhost', 125]]) + const keep = Object.values((await state(restored)).tabs).find((t) => t.url.includes('/page/Keep')) + if (!keep) throw new Error('the tab was not restored') + await cmd(restored, 'tabs.activate', { tabId: keep.id }) + await waitTab(restored, keep.id, '/page/Keep') + await expect.poll(async () => firstZoom(await tabPage(restored, '/page/Keep'))).toBe('1.25') +}) + +test('[BO-02][BO-18] making and editing a Tweak asks no server anything: no request but the page’s own names the site', async () => { + const netLog = join(mkdtempSync(join(tmpdir(), 'orbit-netlog-')), 'netlog.json') + const host = 'tweak-site.test' + orbit = await launchOrbit({ + args: [`--log-net-log=${netLog}`, `--host-resolver-rules=MAP ${host} 127.0.0.1`, `--proxy-bypass-list=${host}`], + }) + const { app, shell } = orbit + const { tabId, page } = await openPage(app, '/page/Private', host) + await shell.getByTestId('address').hover() + await shell.getByTestId('tweak-button').click() + await waitEditorShown(app) + const view = await editorPage(app) + await view.getByTestId('tweak-intro-start').click() + await view.getByTestId('tweak-size-up').click() + await view.getByTestId('tweak-size-up').click() + await expect.poll(() => zoomOf(page)).toBe('1.25') + await pressInPage(app, 'view=tweak', 'Escape') + await waitEditor(app, false) + // A reload asks for the Tweak again, from this computer only. + await mark(page) + await cmd(app, 'tabs.reload', { tabId }) + await newDocument(page) + expect((await firstFrame(page))?.zoom).toBe('1.25') + await app.close() + orbit = undefined + const own = `http://${host}:${fx.port}/` + // Requests that left this computer: only the page's own (its server is the site), and Chromium's + // spell-check dictionary on Linux (see onboarding.spec.ts). + const remote = remoteRequests(netLog).filter( + (url) => !url.startsWith(own) && !/^https:\/\/redirector\.gvt1\.com\/edgedl\/chrome\/dict\//.test(url), + ) + expect(remote).toEqual([]) + // The site's server heard only page loads: nothing about the Tweak. + expect( + fx + .seen() + .filter((r) => r.startsWith(`${host}:`)) + .every((r) => / GET \/page\/Private/.test(r)), + ).toBe(true) +}) diff --git a/tests/unit/tweaks-logic.test.ts b/tests/unit/tweaks-logic.test.ts index 976362e3..4b09b4db 100644 --- a/tests/unit/tweaks-logic.test.ts +++ b/tests/unit/tweaks-logic.test.ts @@ -63,7 +63,15 @@ describe('[BO-17] which pages a Tweak applies to', () => { expect(tweakHostOf('http://example.com./')).toBe('example.com') expect(tweakHostOf('http://127.0.0.1:3300/x')).toBe('127.0.0.1') expect(tweakHostOf('http://[::1]:3300/')).toBe('[::1]') - for (const url of ['orbit://settings/', 'file:///tmp/a.html', 'about:blank', 'data:text/html,x', 'chrome://gpu', '', 'nonsense']) + for (const url of [ + 'orbit://settings/', + 'file:///tmp/a.html', + 'about:blank', + 'data:text/html,x', + 'chrome://gpu', + '', + 'nonsense', + ]) expect(tweakHostOf(url)).toBeNull() }) @@ -109,7 +117,13 @@ describe('[BO-17] which pages a Tweak applies to', () => { describe('[BO-01][BO-18] the saved Tweak', () => { it('starts named after its host, on, changing nothing, its JavaScript allowed on this device', () => { const t = newTweak('t1', 'news.example.com', 42) - expect(t).toMatchObject({ name: 'news.example.com', enabled: true, subdomains: false, jsAllowed: true, jsInPrivate: false }) + expect(t).toMatchObject({ + name: 'news.example.com', + enabled: true, + subdomains: false, + jsAllowed: true, + jsInPrivate: false, + }) expect(t.style).toEqual(DEFAULT_TWEAK_STYLE) expect(isEmptyTweak(t)).toBe(true) }) @@ -118,9 +132,22 @@ describe('[BO-01][BO-18] the saved Tweak', () => { const t = normalizeTweak('x', { host: 'Example.COM.', js: 'console.log(1)', - style: { size: 500, intensity: 2, hue: -30, font: 'nope', textCase: 'shout', hidden: [{ id: 'h', selector: '#ad' }, { selector: 'a{' }] }, + style: { + size: 500, + intensity: 2, + hue: -30, + font: 'nope', + textCase: 'shout', + hidden: [{ id: 'h', selector: '#ad' }, { selector: 'a{' }], + }, + }) + expect(t).toMatchObject({ + host: 'example.com', + name: 'example.com', + enabled: true, + jsAllowed: false, + subdomains: false, }) - expect(t).toMatchObject({ host: 'example.com', name: 'example.com', enabled: true, jsAllowed: false, subdomains: false }) expect(t?.style).toMatchObject({ size: 150, intensity: 1, hue: 330, font: '', textCase: 'none' }) expect(t?.style.hidden).toEqual([{ id: 'h', selector: '#ad', label: '' }]) expect(normalizeTweak('y', { host: '' })).toBeNull() @@ -130,12 +157,20 @@ describe('[BO-01][BO-18] the saved Tweak', () => { it('loads a state file from before Tweaks had their controls, and the switches default on and unseen', () => { const raw = createInitialState(0) as unknown as Record - raw.tweaks = { old: { id: 'old', host: 'example.com', name: 'Mine', enabled: false, css: 'a{}', js: '', createdAt: 3 } } + raw.tweaks = { + old: { id: 'old', host: 'example.com', name: 'Mine', enabled: false, css: 'a{}', js: '', createdAt: 3 }, + } const settings = raw.settings as Record delete settings.tweaksEnabled delete settings.tweakIntroSeen const state = normalizeState(JSON.parse(JSON.stringify(raw)) as Partial) - expect(state.tweaks.old).toMatchObject({ name: 'Mine', enabled: false, css: 'a{}', updatedAt: 3, style: DEFAULT_TWEAK_STYLE }) + expect(state.tweaks.old).toMatchObject({ + name: 'Mine', + enabled: false, + css: 'a{}', + updatedAt: 3, + style: DEFAULT_TWEAK_STYLE, + }) expect(state.settings.tweaksEnabled).toBe(true) expect(state.settings.tweakIntroSeen).toBe(false) }) @@ -258,7 +293,11 @@ describe('[BO-03][BO-04][BO-05][BO-06] colours', () => { seed = (seed * 16807) % 2147483647 return seed / 2147483647 } - for (const s of [style({ invert: true }), style({ hue: 220, accent: 220, intensity: 0.5 }), style({ contrast: 120, brightness: 90 })]) { + for (const s of [ + style({ invert: true }), + style({ hue: 220, accent: 220, intensity: 0.5 }), + style({ contrast: 120, brightness: 90 }), + ]) { const chain = colorChain(s) const inverse = invertAffine(composeChain(chain)) expect(inverse).not.toBeNull() @@ -268,7 +307,13 @@ describe('[BO-03][BO-04][BO-05][BO-06] colours', () => { const grey = 0.25 + random() * 0.5 const x: Rgb = [grey + (random() - 0.5) * 0.2, grey + (random() - 0.5) * 0.2, grey + (random() - 0.5) * 0.2] const undone = [0, 1, 2].map((r) => - Math.min(1, Math.max(0, inverse.m[r * 3]! * x[0] + inverse.m[r * 3 + 1]! * x[1] + inverse.m[r * 3 + 2]! * x[2] + inverse.t[r]!)), + Math.min( + 1, + Math.max( + 0, + inverse.m[r * 3]! * x[0] + inverse.m[r * 3 + 1]! * x[1] + inverse.m[r * 3 + 2]! * x[2] + inverse.t[r]!, + ), + ), ) as Rgb expect(near(runChain(chain, undone), x, 4 / 255)).toBe(true) } @@ -285,8 +330,18 @@ describe('[BO-03][BO-04][BO-05][BO-06] colours', () => { describe('[BO-07][BO-08][BO-09][BO-14] a page’s stylesheets', () => { it('are in the Tweak layer, one for the colours, one for type and one per Tweak', () => { - const a = tweak('a', 'example.com', { css: 'body { color: red }', style: { ...DEFAULT_TWEAK_STYLE, size: 110, invert: true } }, 1) - const b = tweak('b', 'example.com', { style: { ...DEFAULT_TWEAK_STYLE, size: 125, hidden: [{ id: 'h', selector: '.ad', label: 'Ad' }] } }, 2) + const a = tweak( + 'a', + 'example.com', + { css: 'body { color: red }', style: { ...DEFAULT_TWEAK_STYLE, size: 110, invert: true } }, + 1, + ) + const b = tweak( + 'b', + 'example.com', + { style: { ...DEFAULT_TWEAK_STYLE, size: 125, hidden: [{ id: 'h', selector: '.ad', label: 'Ad' }] } }, + 2, + ) const parts = tweakSheets([a, b]) expect(parts.map((p) => p.key)).toEqual(['color', 'base', 't:a', 't:b']) for (const part of parts) expect(part.css.startsWith('@layer orbit-tweaks {')).toBe(true) From 521f4fc892cb82780358af9493867734301d240e Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:23:55 +0000 Subject: [PATCH 04/22] docs(tweaks): D40 entry for the Tweak sheet channel; BO-01/02/18 status; tab menu key hint from the tweaks lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ARCHITECTURE §10: orbit-page:tweak-sheets (BO-18), its own block beside the pages lane's channels; the JavaScript Tweak line says where Tweak scripts run (main, document ready). - feature-status: BO-01 built, BO-02 and BO-18 partial, with notes; FEATURES.md regenerated. - The tab menu's "Tweak this Site…" key hint (KS-18) comes from src/main/tweaks/menu.ts and is joined at the tab menu's call site, so keys' TAB_MENU_KEYS stays as main has it. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 40 ++++++++++++++++++------------------- docs/ARCHITECTURE.md | 3 ++- docs/feature-status.json | 3 +++ src/main/keys/menu-hints.ts | 2 -- src/main/tabs/commands.ts | 5 +++-- src/main/tweaks/menu.ts | 6 +++++- 6 files changed, 33 insertions(+), 26 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index bb613f15..bc125988 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,13 +4,13 @@ Every row is one behaviour from the clean-room spec. Status per ID: **spec’d** (every row) → **built** → **tested** (a unit or e2e test names the ID) → **feels right** (lead sign-off after a hands-on review against `feel.md`). -**Totals:** 472 spec’d · 276 built (77 partial) · 356 tested · 74 feel right. 13 more are out of scope and not counted. +**Totals:** 472 spec’d · 277 built (79 partial) · 373 tested · 74 feel right. 13 more are out of scope and not counted. | Priority | Spec’d | Built | Tested | |---|---|---|---| | P0 | 174 | 152 | 173 | -| P1 | 219 | 107 | 162 | -| P2 | 79 | 17 | 21 | +| P1 | 219 | 108 | 176 | +| P2 | 79 | 17 | 24 | P0 = milestone-1 core experience, P1 = parity, P2 = later. @@ -436,27 +436,27 @@ Spec: `spec/boosts.md` | ID | Feature | P | Built | Tested | Feels right | Notes | |---|---|---|---|---|---|---| -| BO-01 | Create a site customization for the current site | P1 | – | – | – | | -| BO-02 | Editor panel: layout, live preview, close | P1 | – | – | – | | -| BO-03 | Colour wheel (theme tint) | P1 | – | – | – | | -| BO-04 | Invert lightness ("make this site dark") | P1 | – | – | – | | -| BO-05 | Advanced colour sliders | P2 | – | – | – | | -| BO-06 | Reset colours | P1 | – | – | – | | -| BO-07 | Font override presets | P1 | – | – | – | | -| BO-08 | Page size (zoom) control | P1 | – | – | – | | -| BO-09 | Text case control | P2 | – | – | – | | -| BO-10 | Hide-element mode | P1 | – | – | – | | -| BO-11 | Code editor: custom CSS and JavaScript per site | P1 | – | – | – | | +| BO-01 | Create a site customization for the current site | P1 | ✅ | ✅ e2e+unit | – | New Tweak… from the command bar (also "customize", "customization", "site style"), the sidebar's + menu, the site controls panel, a paintbrush in the address field (shown on hover, in the accent colour once the site has a Tweak on) and the tab's menu (Tweak this Site…); none bound to a key. Opens the editor over the page with a new Tweak named after the host; a second one adds a second Tweak. A one-screen intro the first time only. Internal pages, error pages and a Peek take none: the entries are greyed or gone, or say why. Nothing reloads | +| BO-02 | Editor panel: layout, live preview, close | P1 | 🟡 partial | ✅ e2e | – | a small view of its own over the page's top-right corner, sized to the panel, draggable by its title and kept inside the page; the page around it stays live. Every change shows at once and is saved; Esc, the close button and Done close it and give the page the keyboard back; the site controls' › and Edit Tweak reopen it as it was. Another page of the site keeps it open; another site or tab closes it; it steps aside for a Peek or full screen. No request names the site. Controls so far: size (BO-08); the rest come with their rows | +| BO-03 | Colour wheel (theme tint) | P1 | – | ✅ unit | – | | +| BO-04 | Invert lightness ("make this site dark") | P1 | – | ✅ unit | – | | +| BO-05 | Advanced colour sliders | P2 | – | ✅ unit | – | | +| BO-06 | Reset colours | P1 | – | ✅ unit | – | | +| BO-07 | Font override presets | P1 | – | ✅ unit | – | | +| BO-08 | Page size (zoom) control | P1 | – | ✅ e2e+unit | – | | +| BO-09 | Text case control | P2 | – | ✅ unit | – | | +| BO-10 | Hide-element mode | P1 | – | ✅ unit | – | | +| BO-11 | Code editor: custom CSS and JavaScript per site | P1 | – | ✅ unit | – | | | BO-12 | Name, rename and reset all edits | P1 | – | – | – | | | BO-13 | Shuffle (random style) | P2 | – | – | – | | -| BO-14 | Enable / disable a customization (per site) | P1 | – | – | – | | +| BO-14 | Enable / disable a customization (per site) | P1 | – | ✅ unit | – | | | BO-15 | Manage all customizations (list view in the Library) | P1 | – | – | – | | -| BO-16 | Global on/off switch for all customizations | P1 | – | – | – | | -| BO-17 | Scope: which pages a customization applies to | P1 | – | – | – | | -| BO-18 | How customizations apply on page load (timing, safety) | P1 | – | – | – | | -| BO-19 | Use Space theme colours inside customization CSS | P2 | – | – | – | | +| BO-16 | Global on/off switch for all customizations | P1 | – | ✅ unit | – | | +| BO-17 | Scope: which pages a customization applies to | P1 | – | ✅ unit | – | | +| BO-18 | How customizations apply on page load (timing, safety) | P1 | 🟡 partial | ✅ e2e+unit | – | the page preload asks main for the site's sheets before the page's first paint (D40), so no frame shows the page without its Tweak (frame-capture test on a slow page's hard reload and first visit); main swaps in its own copies at document ready. Survives reloads, back and forward, a reopened tab, a woken tab and a restart. JavaScript runs from main once per load at document ready, main frame only, where allowed (its editor comes with BO-11); the Space colours come with BO-19 | +| BO-19 | Use Space theme colours inside customization CSS | P2 | – | ✅ unit | – | | | BO-20 | Sharing customizations and the customization gallery | P2 | – | – | – | | -| BO-21 | Customization commands in the command bar | P1 | – | – | – | | +| BO-21 | Customization commands in the command bar | P1 | – | ✅ e2e | – | | | BO-22 | Legacy code-only customization builder | OOS | ⛔ out of scope | – | – | the spec leaves it out | ## Chrome extension support diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 27d9ff59..fcee0d23 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -151,6 +151,7 @@ Sandboxed, context-isolated renderers everywhere. No Node in pages. Pages get on - `orbit-page:swipe` (NV-02): Electron reports no scroll phases to main, so a two-finger swipe over the page is told from the page's own wheel events. A passive listener in the preload's own world takes trusted pixel-delta wheel events in the main frame, after the page's handlers have run, and recognises a horizontal swipe (`src/preload/swipe.ts`). Unless something under the pointer can still scroll that way or the page called `preventDefault`, it sends how far the swipe has gone (`move`, a direction and px) and whether it was let go with the arrow fully in (`end`); nothing else. Main takes it only from a main frame of a page in a window's page card, clamps the travel, shows the arrow only when that page can go that way, and goes back or forward as the Back button does: no more than the page could do itself (`history.back()`). A new use under D40 (4), for the lead to log. - `orbit-page:print` (NV-07): the main frame's `window.print()`, patched (D40), asks main for Orbit's print dialog over that page, synchronously, and returns as soon as main replies whether the dialog the page's own `print()` opened is open now; main answers at once and waits on nothing, and nothing else goes with it. A `print()` while a dialog that ⌘P or a menu opened shows returns at once and holds nothing, as Chromium's does (D76). Main takes it only from a main frame. In the page's `beforeunload`, `pagehide` or `unload` handlers it does nothing, as the engine's does. While the dialog the page asked for is open, what would take the page away waits, as it would behind the engine's `print()`, which returns only once its dialog has closed: the page's `window.close()`, patched (D40) with the built-ins it calls taken before the page runs, which waits only while that dialog is open (D76), and a navigation the page starts that the Navigation API lets the preload stop, from its own world, and start again as it was (a link, `location`, a form that gets its page, a reload). When the dialog closes, main tells the page whose `print()` opened it, and only that page, on `orbit-page:print-closed` (main → page, with nothing in it, D76), and the close goes on, or else the page's last such navigation starts again. A form the page posts and a move through history go at once, and take the dialog with them. A frame's own `window.print()` isn't patched: Electron opens the system's print dialog. - `orbit-page:screen-share` (MD-11, TB-15, D69): the count of the main frame's live shared-screen tracks, sent when it changes. The preload wraps `getDisplayMedia` and `MediaStreamTrack` `stop` and `clone` to follow the tracks that call returns. Main takes the count only from a tab's main frame (same process and routing id as `mainFrame`), clamps it to 0…1000, and uses it only for that tab's sharing indicators (the row, and the recent-tab switcher once it shows one): nothing that grants, refuses, sleeps or archives reads it (`TabMedia.screen` vs `screenGranted`). It sees the shares the system's own picker grants without asking Orbit (macOS 15 and later). Known limit: neither this nor main's watcher (below) holds against a page that wants to hide its share; it can clone a track with another realm's built-ins, such as a same-origin frame's, and stop the one Orbit follows. The OS's own capture indicator is the backstop where the OS has one. +- `orbit-page:tweak-sheets` (BO-18): a synchronous read, before the page's first paint, of the stylesheets of the Tweaks that apply to the sending frame's own site; the message carries nothing. Main answers only a main frame (same process and routing id as `mainFrame`) of a page in a profile's session, so never one of Orbit's own views, from the frame's own URL: `http:` and `https:` only, so never an `orbit:` page, and only with the CSS of the Tweaks on for that host while Tweaks are on (BO-16). The preload adopts the sheets from its own world as constructed sheets marked with the media `orbit-tweaks` (`TWEAK_SHEET_MEDIA`): no global. A PDF in the engine's viewer asks nothing. At document ready main puts in its own copies with `insertCSS` (which the page can neither see nor remove) and takes the preload's out from isolated world 1040; from then on an edit replaces only the sheets it changed, the new one in before the old one comes out (`src/main/tweaks/pages.ts`). Known limit: until document ready, the page's own scripts can see the preload's sheets in `document.adoptedStyleSheets`, and remove them. A Tweak's JavaScript never goes through the preload (below). Read-only. A page's dialog (NV-24) and its error or crash screen (NV-13, NV-14) are Orbit UI views placed over that page's area only (`src/main/pages/covers.ts`): a tab's page, a split pane, a Peek's or a Mini Window's page, or a pop-up's. A dialog or a screen takes the keyboard when it shows over the page the user is on, but only where the page had it, never from Orbit's own fields (the find bar, a sidebar field, the address field), so a page can't time a dialog to catch keys meant for Orbit (D72); it takes the keyboard once the user clicks it or the keyboard next goes to the page. A page's dialog and the print dialog stay above the window's page tools (the find bar) in a tab and over a Peek's page alike (`WindowManager.keepAboveTools`, PK-11), so the find bar keeps the keyboard it has under a dialog it can't cover; error and crash screens aren't kept so, and the find bar can show over them. While a cover is on screen, its window counts as showing a dialog for the floating player (`withDialog`, `src/main/media/dialog-watch.ts`, MD-06), which keeps clear of it; so do Save Page As… and Save as PDF's save dialogs. An error screen covers the engine's own error document rather than replacing it, so the failed address, Reload and Back stay the engine's. Certificate errors (`app.on('certificate-error')`, `src/main/pages/screens.ts`): every certificate the system doesn't trust is refused, and the screen offers Back to safety, with Proceed behind Advanced (never for a revoked one). Proceed lets that certificate (by fingerprint) through for that host only, in that profile's session, until Orbit quits. @@ -164,7 +165,7 @@ Media in frames (embedded players, MD-01, MD-09; D67's trade-off): a frame that Screen sharing (MD-11, D69): when Orbit's screen chooser grants a share to any frame of a tab, its own page included, main puts a script in that frame's own world (`src/main/media/screen-share.ts`) before the stream reaches the page, on a hidden property named at random for each document. Each grant counts one; the script reports the shared tracks the page stops, the clones it makes and the sources that end, and the share ends with the document. Only these grants keep a tab awake and unarchived (`screenGranted`, TB-16, TD-02). Like media's frame agent, its reports are the page's word and change only that tab's indicators and busy mark. An indicator shows while this or the preload's count says a share is live. -The UI's `orbit:*` IPC accepts registered Orbit UI senders only. UI renderers can't navigate or open windows. Permission prompts are per origin. Tweak JS (user-authored, per site) is injected only through `webFrame.executeJavaScript` in an isolated world, never with Node. Extensions use Electron's built-in extension support plus Orbit's own (MIT) API shims (D3: no GPL dependency). +The UI's `orbit:*` IPC accepts registered Orbit UI senders only. UI renderers can't navigate or open windows. Permission prompts are per origin. Tweak JavaScript (user-written, per site; BO-11, BO-18) runs from main in the page's own world, in its main frame only, once per load at document ready (`webFrameMain.executeJavaScript`), and only where the user allows it on this device (an imported one arrives off; in private windows only if allowed): never through the preload, never on `orbit:` pages or in Orbit's own views, never with Node. An error it throws goes to the page's console. Extensions use Electron's built-in extension support plus Orbit's own (MIT) API shims (D3: no GPL dependency). ## 11. Packaging and updates diff --git a/docs/feature-status.json b/docs/feature-status.json index e98ed3a7..507af13e 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -358,6 +358,9 @@ "FL-25": {"built": "yes", "note": "folder search and icon picker: drawn in the first frame, from 0.97 with the snappy timing (150 ms); close with the quick fade; keys go back to the sidebar at once"}, "FL-28": {"built": "yes", "note": "follows the OS setting, with a setting to force it on (AP-17); springs, slides and scales become short fades or instant changes, toasts included, also for what is already on screen when it turns on; a unit guard checks that every motion component sits under the config or reads the setting, and that every element with a layout animation, an entrance, an exit, a press or a moving animate asks useReducedMotion(), since motion reads the config only as an element mounts; drags still follow the pointer and settle with a short ease"}, "AP-17": {"built": "partial", "note": "one set of motion tokens for every surface (FL-01): ease-out curves of 90–270 ms and springs of 0.25–0.32 s; buttons and the × press to 0.9 (FL-08); the loading pill at the top centre of the card (FL-23); reduced motion from the OS setting or Orbit's own switch in Appearance, turning slides, flights and springs into short fades (FL-28). No shimmer when the app starts"}, + "BO-01": {"built": "yes", "note": "New Tweak… from the command bar (also \"customize\", \"customization\", \"site style\"), the sidebar's + menu, the site controls panel, a paintbrush in the address field (shown on hover, in the accent colour once the site has a Tweak on) and the tab's menu (Tweak this Site…); none bound to a key. Opens the editor over the page with a new Tweak named after the host; a second one adds a second Tweak. A one-screen intro the first time only. Internal pages, error pages and a Peek take none: the entries are greyed or gone, or say why. Nothing reloads"}, + "BO-02": {"built": "partial", "note": "a small view of its own over the page's top-right corner, sized to the panel, draggable by its title and kept inside the page; the page around it stays live. Every change shows at once and is saved; Esc, the close button and Done close it and give the page the keyboard back; the site controls' › and Edit Tweak reopen it as it was. Another page of the site keeps it open; another site or tab closes it; it steps aside for a Peek or full screen. No request names the site. Controls so far: size (BO-08); the rest come with their rows"}, + "BO-18": {"built": "partial", "note": "the page preload asks main for the site's sheets before the page's first paint (D40), so no frame shows the page without its Tweak (frame-capture test on a slow page's hard reload and first visit); main swaps in its own copies at document ready. Survives reloads, back and forward, a reopened tab, a woken tab and a restart. JavaScript runs from main once per load at document ready, main frame only, where allowed (its editor comes with BO-11); the Space colours come with BO-19"}, "FL-29": {"built": "partial", "note": "measured by the feel specs against each budget (docs/FEEL.md); press, ⌘W, ⌘S, Space switch (settles in ~290 ms), command bar and drag budgets met; still missed: p99 at two intervals in some animations under load (⌘S hide, long lists, a Peek opening), the hover sidebar and a Peek's close start in the second frame, and the first ⌘T or ⌘W after a launch can show in the second frame. The rubber band comes back in ~480 ms on FL-13's own spring: the 350 ms settle is for swipes that land on a Space (D70)"}, "FL-30": {"built": "partial", "note": "the nine points as a checklist in docs/FEEL.md: pass, gap or fixed, each with the test that shows it"}, "FL-18": {"built": "partial", "note": "a tab dragged over the page: the zone fades in over 100 ms on the half the new pane takes and moves between halves at once; the new pane grows in from its side with the standard spring; the divider follows the pointer 1:1; flip and close travel with the spring (docs/FEEL.md); a tab dragged onto another tab row highlights the half (SV-05) but has no bubble preview or wobble on drop"}, diff --git a/src/main/keys/menu-hints.ts b/src/main/keys/menu-hints.ts index bd8f20d9..9388808b 100644 --- a/src/main/keys/menu-hints.ts +++ b/src/main/keys/menu-hints.ts @@ -28,8 +28,6 @@ export const TAB_MENU_KEYS: KeyHints = [ ['Duplicate', 'tab.duplicate'], ['Mute Tab', 'tab.toggleMute'], ['Unmute Tab', 'tab.toggleMute'], - // From the tweaks lane (src/main/tweaks/menu.ts, BO-01): no key unless the user gives it one. - ['Tweak this Site…', 'tweak.new'], ['Close Tab', 'tab.close'], [/^Close \d+ Tabs$/, 'tab.close'], ] diff --git a/src/main/tabs/commands.ts b/src/main/tabs/commands.ts index 378db6a2..60fe47f1 100644 --- a/src/main/tabs/commands.ts +++ b/src/main/tabs/commands.ts @@ -29,7 +29,7 @@ import { TAB_MENU_KEYS, withKeyHints } from '../keys/menu-hints' import { printPage, savePage, viewSource } from '../pages/entry' import type { CallContext } from '../registry' import { tabSplitMenuItems } from '../split/menu' -import { tabTweakMenuItems } from '../tweaks/menu' +import { TWEAK_TAB_MENU_KEYS, tabTweakMenuItems } from '../tweaks/menu' import { TabMaintenance } from './maintenance' import { findMenuItem, folderMenu, type MenuOps, menuLabels, tabMenu } from './menus' import { makeTab, numberedTarget, steppedTarget, todayInsertIndex } from './model' @@ -378,7 +378,8 @@ export function registerTabs(ctx: MainContext): { ops: TabOps; maintenance: TabM const state = store.state const mo = menuOps(windowId) // Keys lane: an item that a key also does shows the key (KS-18). - const forTab = (tabId: TabId) => withKeyHints(tabMenu(state, tabId, selection, mo), TAB_MENU_KEYS) + const forTab = (tabId: TabId) => + withKeyHints(tabMenu(state, tabId, selection, mo), [...TAB_MENU_KEYS, ...TWEAK_TAB_MENU_KEYS]) const all = isFolderId(id) ? folderMenu(state, id, mo) : isTabId(id) ? forTab(id) : [] // Private windows have no pinned tabs, Favorites, folders or other Spaces (NV-11, SB-25). const items = isTabId(id) && isPrivateTab(state, id) ? privateTabMenu(all) : all diff --git a/src/main/tweaks/menu.ts b/src/main/tweaks/menu.ts index 69f83d93..4e1d2220 100644 --- a/src/main/tweaks/menu.ts +++ b/src/main/tweaks/menu.ts @@ -2,14 +2,18 @@ import type { MenuItemConstructorOptions } from 'electron' import type { TabId, WindowId } from '../../shared/state' import { tweakHostOf } from '../../shared/tweaks' import type { MainContext } from '../context' +import type { KeyHints } from '../keys/menu-hints' /** The tab menu's item (BO-01). */ export const TAB_MENU_TWEAK = 'Tweak this Site…' +/** Its key hint (KS-18): New Tweak's key, once the user gives it one (none by default). */ +export const TWEAK_TAB_MENU_KEYS: KeyHints = [[TAB_MENU_TWEAK, 'tweak.new']] + /** * "Tweak this Site…" in a tab's menu (src/main/tabs/menus.ts, BO-01): a new Tweak for the tab's * site, the tab shown first. Only on a website, and while Tweaks are on (BO-16). It shows the key - * of New Tweak when the user gave it one (TAB_MENU_KEYS, KS-18). + * of New Tweak when the user gave it one (TWEAK_TAB_MENU_KEYS, KS-18). */ export function tabTweakMenuItems( ctx: MainContext, From ebee412bded8cc4cc4cd5fef8158a3457400055d Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:46:17 +0000 Subject: [PATCH 05/22] feat(tweaks): CSS and JavaScript in the editor, scope and site switches (BO-11, BO-14, BO-17, BO-21) - BO-11: a code area in the Tweak editor with CSS and JavaScript tabs, a home-grown code editor (text field over a coloured copy, line numbers, Tab/Shift+Tab, Enter keeps indentation, the platform's own undo). Typed CSS reaches the page in about 0.1 s. JavaScript runs from main at document ready, once per load, in the page's main world; "Run now" and "Reload page" buttons. Editing JavaScript never allows it; JavaScript from elsewhere waits for "Allow". Errors go to the page's console. - BO-14: each Tweak has its own switch in site controls; the paintbrush turns the site's Tweaks off and on. - BO-17: "Also apply to subdomains" (not for IP hosts; fixed when edited from a subdomain page), every profile, private windows get CSS and, only when the Tweak allows it, JavaScript. - BO-21: the command bar finds Tweak actions by "customize", "customization" and "site style". Tests without the fix (D18): - no flush before closing: a fresh Tweak's typed CSS is lost (received undefined) - auto-allow on a JavaScript edit: jsAllowed became true - private-window check skipped: the script ran in a private window - subdomain box not fixed: toBeDisabled failed Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 20 +- docs/feature-status.json | 8 +- src/main/tweaks/index.ts | 2 + src/main/tweaks/pages.ts | 18 +- src/renderer/src/tweaks/CodeArea.tsx | 170 ++++++ src/renderer/src/tweaks/CodeEditor.tsx | 153 +++++ src/renderer/src/tweaks/TweakEditorApp.tsx | 90 ++- src/renderer/src/tweaks/code.css | 292 ++++++++++ src/renderer/src/tweaks/edit.ts | 17 + src/renderer/src/tweaks/highlight.ts | 271 +++++++++ src/renderer/src/tweaks/tweaks.css | 3 +- .../src/tweaks/tweaks.site-controls.tsx | 1 + src/shared/commands/tweaks.ts | 6 + src/shared/tweaks.ts | 14 +- tests/e2e/tweaks-core.spec.ts | 527 ++++++++++++++++++ tests/e2e/tweaks-support.ts | 77 ++- tests/e2e/tweaks.spec.ts | 43 +- tests/unit/tweaks-highlight.test.ts | 51 ++ tests/unit/tweaks-logic.test.ts | 9 +- 19 files changed, 1694 insertions(+), 78 deletions(-) create mode 100644 src/renderer/src/tweaks/CodeArea.tsx create mode 100644 src/renderer/src/tweaks/CodeEditor.tsx create mode 100644 src/renderer/src/tweaks/code.css create mode 100644 src/renderer/src/tweaks/highlight.ts create mode 100644 tests/e2e/tweaks-core.spec.ts create mode 100644 tests/unit/tweaks-highlight.test.ts diff --git a/FEATURES.md b/FEATURES.md index bc125988..828fa4e3 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,13 +4,13 @@ Every row is one behaviour from the clean-room spec. Status per ID: **spec’d** (every row) → **built** → **tested** (a unit or e2e test names the ID) → **feels right** (lead sign-off after a hands-on review against `feel.md`). -**Totals:** 472 spec’d · 277 built (79 partial) · 373 tested · 74 feel right. 13 more are out of scope and not counted. +**Totals:** 472 spec’d · 280 built (80 partial) · 374 tested · 74 feel right. 13 more are out of scope and not counted. | Priority | Spec’d | Built | Tested | |---|---|---|---| | P0 | 174 | 152 | 173 | -| P1 | 219 | 108 | 176 | -| P2 | 79 | 17 | 24 | +| P1 | 219 | 111 | 176 | +| P2 | 79 | 17 | 25 | P0 = milestone-1 core experience, P1 = parity, P2 = later. @@ -437,7 +437,7 @@ Spec: `spec/boosts.md` | ID | Feature | P | Built | Tested | Feels right | Notes | |---|---|---|---|---|---|---| | BO-01 | Create a site customization for the current site | P1 | ✅ | ✅ e2e+unit | – | New Tweak… from the command bar (also "customize", "customization", "site style"), the sidebar's + menu, the site controls panel, a paintbrush in the address field (shown on hover, in the accent colour once the site has a Tweak on) and the tab's menu (Tweak this Site…); none bound to a key. Opens the editor over the page with a new Tweak named after the host; a second one adds a second Tweak. A one-screen intro the first time only. Internal pages, error pages and a Peek take none: the entries are greyed or gone, or say why. Nothing reloads | -| BO-02 | Editor panel: layout, live preview, close | P1 | 🟡 partial | ✅ e2e | – | a small view of its own over the page's top-right corner, sized to the panel, draggable by its title and kept inside the page; the page around it stays live. Every change shows at once and is saved; Esc, the close button and Done close it and give the page the keyboard back; the site controls' › and Edit Tweak reopen it as it was. Another page of the site keeps it open; another site or tab closes it; it steps aside for a Peek or full screen. No request names the site. Controls so far: size (BO-08); the rest come with their rows | +| BO-02 | Editor panel: layout, live preview, close | P1 | 🟡 partial | ✅ e2e | – | a small view of its own over the page's top-right corner, sized to the panel, draggable by its title and kept inside the page; the page around it stays live. Every change shows at once and is saved; Esc, the close button and Done close it and give the page the keyboard back; the site controls' › and Edit Tweak reopen it as it was. Another page of the site keeps it open; another site or tab closes it; it steps aside for a Peek or full screen. No request names the site. Controls so far: size (BO-08), the code area (BO-11, which widens the panel to the left) and "Also apply to subdomains" (BO-17); the rest come with their rows | | BO-03 | Colour wheel (theme tint) | P1 | – | ✅ unit | – | | | BO-04 | Invert lightness ("make this site dark") | P1 | – | ✅ unit | – | | | BO-05 | Advanced colour sliders | P2 | – | ✅ unit | – | | @@ -446,17 +446,17 @@ Spec: `spec/boosts.md` | BO-08 | Page size (zoom) control | P1 | – | ✅ e2e+unit | – | | | BO-09 | Text case control | P2 | – | ✅ unit | – | | | BO-10 | Hide-element mode | P1 | – | ✅ unit | – | | -| BO-11 | Code editor: custom CSS and JavaScript per site | P1 | – | ✅ unit | – | | +| BO-11 | Code editor: custom CSS and JavaScript per site | P1 | ✅ | ✅ e2e+unit | – | the { } button opens a code area with CSS and JavaScript tabs, empty to start. CSS applies as it is typed (about 90 ms from the keystroke to the page's frame, measured) and is saved as typed, sent before the panel closes; a reload keeps it. JavaScript runs in the page's own world once per load at document ready, from main; Run now runs it at once, Reload page loads the page again. The editor: monospace, syntax colouring (text only, never markup), line numbers, the platform's undo (⌘Z / Ctrl+Z through Orbit's key handling), Tab and ⇧Tab indent and outdent, Enter keeps the indentation. Errors, thrown or syntax, go to the page's console; no dialog, nothing in main. JavaScript from elsewhere stays off until Allow on this device, and editing it doesn't allow it. A JS badge in the site controls list and the code tab (the Library's list comes with BO-15) | | BO-12 | Name, rename and reset all edits | P1 | – | – | – | | | BO-13 | Shuffle (random style) | P2 | – | – | – | | -| BO-14 | Enable / disable a customization (per site) | P1 | – | ✅ unit | – | | +| BO-14 | Enable / disable a customization (per site) | P1 | ✅ | ✅ e2e+unit | – | the site controls panel lists the site's Tweaks by name with a paintbrush, coloured when on and grey when off; the name turns one off or on at once, without a reload, each on its own, keeping all its settings; the › opens it in the editor. The address field's paintbrush shows in the accent colour while one is on and grey when all are off, and turns them all off or on | | BO-15 | Manage all customizations (list view in the Library) | P1 | – | – | – | | | BO-16 | Global on/off switch for all customizations | P1 | – | ✅ unit | – | | -| BO-17 | Scope: which pages a customization applies to | P1 | – | ✅ unit | – | | -| BO-18 | How customizations apply on page load (timing, safety) | P1 | 🟡 partial | ✅ e2e+unit | – | the page preload asks main for the site's sheets before the page's first paint (D40), so no frame shows the page without its Tweak (frame-capture test on a slow page's hard reload and first visit); main swaps in its own copies at document ready. Survives reloads, back and forward, a reopened tab, a woken tab and a restart. JavaScript runs from main once per load at document ready, main frame only, where allowed (its editor comes with BO-11); the Space colours come with BO-19 | +| BO-17 | Scope: which pages a customization applies to | P1 | ✅ | ✅ e2e+unit | – | a Tweak is for its host's pages: any path, query and in-page navigation, not another host, the parent domain or a subdomain. "Also apply to subdomains" (off by default, at the panel's foot) brings in subdomains' open pages at once; it isn't offered for an address by number and is fixed while the page is on a subdomain it brings in. Every profile gets the same Tweaks; in a private window the CSS applies and the JavaScript runs only once "Also run in private windows" is ticked. Matching is a map lookup per label of the host | +| BO-18 | How customizations apply on page load (timing, safety) | P1 | 🟡 partial | ✅ e2e+unit | – | the page preload asks main for the site's sheets before the page's first paint (D40), so no frame shows the page without its Tweak (frame-capture test on a slow page's hard reload and first visit); main swaps in its own copies at document ready. Survives reloads, back and forward, a reopened tab, a woken tab and a restart. JavaScript runs from main once per load at document ready, main frame only, where allowed (BO-11, BO-17): not again on an in-page navigation, never in a frame from another site; the Space colours come with BO-19 | | BO-19 | Use Space theme colours inside customization CSS | P2 | – | ✅ unit | – | | -| BO-20 | Sharing customizations and the customization gallery | P2 | – | – | – | | -| BO-21 | Customization commands in the command bar | P1 | – | ✅ e2e | – | | +| BO-20 | Sharing customizations and the customization gallery | P2 | – | ✅ e2e | – | | +| BO-21 | Customization commands in the command bar | P1 | 🟡 partial | ✅ e2e | – | in the command bar, none bound to a key: New Tweak… (also "customize", "customization", "site style"), Edit Tweak (the site's one in the editor; with several, the site controls list them to pick from; with none, a note says so), Turn Off and Turn On Tweaks for This Site (with a note). View Tweaks comes with BO-15, Export Tweak… with BO-20 | | BO-22 | Legacy code-only customization builder | OOS | ⛔ out of scope | – | – | the spec leaves it out | ## Chrome extension support diff --git a/docs/feature-status.json b/docs/feature-status.json index 507af13e..50793c1b 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -359,8 +359,12 @@ "FL-28": {"built": "yes", "note": "follows the OS setting, with a setting to force it on (AP-17); springs, slides and scales become short fades or instant changes, toasts included, also for what is already on screen when it turns on; a unit guard checks that every motion component sits under the config or reads the setting, and that every element with a layout animation, an entrance, an exit, a press or a moving animate asks useReducedMotion(), since motion reads the config only as an element mounts; drags still follow the pointer and settle with a short ease"}, "AP-17": {"built": "partial", "note": "one set of motion tokens for every surface (FL-01): ease-out curves of 90–270 ms and springs of 0.25–0.32 s; buttons and the × press to 0.9 (FL-08); the loading pill at the top centre of the card (FL-23); reduced motion from the OS setting or Orbit's own switch in Appearance, turning slides, flights and springs into short fades (FL-28). No shimmer when the app starts"}, "BO-01": {"built": "yes", "note": "New Tweak… from the command bar (also \"customize\", \"customization\", \"site style\"), the sidebar's + menu, the site controls panel, a paintbrush in the address field (shown on hover, in the accent colour once the site has a Tweak on) and the tab's menu (Tweak this Site…); none bound to a key. Opens the editor over the page with a new Tweak named after the host; a second one adds a second Tweak. A one-screen intro the first time only. Internal pages, error pages and a Peek take none: the entries are greyed or gone, or say why. Nothing reloads"}, - "BO-02": {"built": "partial", "note": "a small view of its own over the page's top-right corner, sized to the panel, draggable by its title and kept inside the page; the page around it stays live. Every change shows at once and is saved; Esc, the close button and Done close it and give the page the keyboard back; the site controls' › and Edit Tweak reopen it as it was. Another page of the site keeps it open; another site or tab closes it; it steps aside for a Peek or full screen. No request names the site. Controls so far: size (BO-08); the rest come with their rows"}, - "BO-18": {"built": "partial", "note": "the page preload asks main for the site's sheets before the page's first paint (D40), so no frame shows the page without its Tweak (frame-capture test on a slow page's hard reload and first visit); main swaps in its own copies at document ready. Survives reloads, back and forward, a reopened tab, a woken tab and a restart. JavaScript runs from main once per load at document ready, main frame only, where allowed (its editor comes with BO-11); the Space colours come with BO-19"}, + "BO-02": {"built": "partial", "note": "a small view of its own over the page's top-right corner, sized to the panel, draggable by its title and kept inside the page; the page around it stays live. Every change shows at once and is saved; Esc, the close button and Done close it and give the page the keyboard back; the site controls' › and Edit Tweak reopen it as it was. Another page of the site keeps it open; another site or tab closes it; it steps aside for a Peek or full screen. No request names the site. Controls so far: size (BO-08), the code area (BO-11, which widens the panel to the left) and \"Also apply to subdomains\" (BO-17); the rest come with their rows"}, + "BO-11": {"built": "yes", "note": "the { } button opens a code area with CSS and JavaScript tabs, empty to start. CSS applies as it is typed (about 90 ms from the keystroke to the page's frame, measured) and is saved as typed, sent before the panel closes; a reload keeps it. JavaScript runs in the page's own world once per load at document ready, from main; Run now runs it at once, Reload page loads the page again. The editor: monospace, syntax colouring (text only, never markup), line numbers, the platform's undo (⌘Z / Ctrl+Z through Orbit's key handling), Tab and ⇧Tab indent and outdent, Enter keeps the indentation. Errors, thrown or syntax, go to the page's console; no dialog, nothing in main. JavaScript from elsewhere stays off until Allow on this device, and editing it doesn't allow it. A JS badge in the site controls list and the code tab (the Library's list comes with BO-15)"}, + "BO-14": {"built": "yes", "note": "the site controls panel lists the site's Tweaks by name with a paintbrush, coloured when on and grey when off; the name turns one off or on at once, without a reload, each on its own, keeping all its settings; the › opens it in the editor. The address field's paintbrush shows in the accent colour while one is on and grey when all are off, and turns them all off or on"}, + "BO-17": {"built": "yes", "note": "a Tweak is for its host's pages: any path, query and in-page navigation, not another host, the parent domain or a subdomain. \"Also apply to subdomains\" (off by default, at the panel's foot) brings in subdomains' open pages at once; it isn't offered for an address by number and is fixed while the page is on a subdomain it brings in. Every profile gets the same Tweaks; in a private window the CSS applies and the JavaScript runs only once \"Also run in private windows\" is ticked. Matching is a map lookup per label of the host"}, + "BO-18": {"built": "partial", "note": "the page preload asks main for the site's sheets before the page's first paint (D40), so no frame shows the page without its Tweak (frame-capture test on a slow page's hard reload and first visit); main swaps in its own copies at document ready. Survives reloads, back and forward, a reopened tab, a woken tab and a restart. JavaScript runs from main once per load at document ready, main frame only, where allowed (BO-11, BO-17): not again on an in-page navigation, never in a frame from another site; the Space colours come with BO-19"}, + "BO-21": {"built": "partial", "note": "in the command bar, none bound to a key: New Tweak… (also \"customize\", \"customization\", \"site style\"), Edit Tweak (the site's one in the editor; with several, the site controls list them to pick from; with none, a note says so), Turn Off and Turn On Tweaks for This Site (with a note). View Tweaks comes with BO-15, Export Tweak… with BO-20"}, "FL-29": {"built": "partial", "note": "measured by the feel specs against each budget (docs/FEEL.md); press, ⌘W, ⌘S, Space switch (settles in ~290 ms), command bar and drag budgets met; still missed: p99 at two intervals in some animations under load (⌘S hide, long lists, a Peek opening), the hover sidebar and a Peek's close start in the second frame, and the first ⌘T or ⌘W after a launch can show in the second frame. The rubber band comes back in ~480 ms on FL-13's own spring: the 350 ms settle is for swipes that land on a Space (D70)"}, "FL-30": {"built": "partial", "note": "the nine points as a checklist in docs/FEEL.md: pass, gap or fixed, each with the test that shows it"}, "FL-18": {"built": "partial", "note": "a tab dragged over the page: the zone fades in over 100 ms on the half the new pane takes and moves between halves at once; the new pane grows in from its side with the standard spring; the divider follows the pointer 1:1; flip and close travel with the spring (docs/FEEL.md); a tab dragged onto another tab row highlights the half (SV-05) but has no bubble preview or wobble on drop"}, diff --git a/src/main/tweaks/index.ts b/src/main/tweaks/index.ts index efa876b0..c76accb6 100644 --- a/src/main/tweaks/index.ts +++ b/src/main/tweaks/index.ts @@ -112,6 +112,8 @@ export function registerTweaks(ctx: MainContext): void { return true }) + commands.register('tweaks.runNow', ({ id, tabId }) => pages.runNow(tabId, id)) + commands.register('tweaks.setEnabled', ({ id, enabled }) => { if (typeof enabled !== 'boolean') return store.update((draft) => { diff --git a/src/main/tweaks/pages.ts b/src/main/tweaks/pages.ts index 3de7bc22..b2be038f 100644 --- a/src/main/tweaks/pages.ts +++ b/src/main/tweaks/pages.ts @@ -1,7 +1,7 @@ import { type IpcMainEvent, ipcMain, type Session, type WebContents } from 'electron' import { isPrivateProfileId } from '../../shared/private' import { PAGE_CHANNEL } from '../../shared/site' -import type { OrbitState, ProfileId, TabId } from '../../shared/state' +import type { OrbitState, ProfileId, TabId, Tweak } from '../../shared/state' import { enabledTweaksFor, type SheetOptions, @@ -156,14 +156,20 @@ export function registerTweakPages(ctx: MainContext): TweakPages { } } + /** + * A Tweak's JavaScript may run in a page: it has some, the user allowed it on this device, and + * in a private window allowed it there too (BO-11, BO-17, BO-18). + */ + const mayRun = (record: PageRecord, tweak: Tweak): boolean => + !!tweak.js.trim() && tweak.jsAllowed && (tweak.jsInPrivate || !isPrivateProfileId(profileOf(record.contents))) + /** Runs the JavaScript of a page's Tweaks once per load, where allowed (BO-11, BO-17, BO-18). */ const runScripts = (record: PageRecord) => { if (record.scripted || !current(record)) return record.scripted = true const state = store.state - const inPrivate = isPrivateProfileId(profileOf(record.contents)) for (const tweak of enabledTweaksFor(state.tweaks, state.settings, record.host)) { - if (!tweak.js.trim() || !tweak.jsAllowed || (inPrivate && !tweak.jsInPrivate)) continue + if (!mayRun(record, tweak)) continue // A script error goes to the page's console; a rejection here is only that (D28). record.contents.mainFrame.executeJavaScript(tweakScript(tweak)).catch(() => {}) } @@ -263,9 +269,9 @@ export function registerTweakPages(ctx: MainContext): TweakPages { hostOf: (tabId) => recordOf(tabId)?.host ?? null, runNow: (tabId, tweakId) => { const record = recordOf(tabId) - const tweak = store.state.tweaks[tweakId] - if (!record || !tweak?.js.trim() || !tweak.jsAllowed) return false - if (isPrivateProfileId(profileOf(record.contents)) && !tweak.jsInPrivate) return false + const state = store.state + const tweak = record && enabledTweaksFor(state.tweaks, state.settings, record.host).find((t) => t.id === tweakId) + if (!record || !tweak || !mayRun(record, tweak)) return false record.contents.mainFrame.executeJavaScript(tweakScript(tweak)).catch(() => {}) return true }, diff --git a/src/renderer/src/tweaks/CodeArea.tsx b/src/renderer/src/tweaks/CodeArea.tsx new file mode 100644 index 00000000..8e32bdf3 --- /dev/null +++ b/src/renderer/src/tweaks/CodeArea.tsx @@ -0,0 +1,170 @@ +import { Play, RotateCw, ShieldAlert } from 'lucide-react' +import { useCallback, useEffect, useRef, useState } from 'react' +import type { TweakEditorState } from '../../../shared/commands/tweaks' +import type { Tweak } from '../../../shared/state' +import { TWEAK_LIMITS } from '../../../shared/tweaks' +import { invoke } from '../api' +import { CodeEditor } from './CodeEditor' +import { flushTweakEdits, holdEdits, updateTweak } from './edit' +import type { CodeLanguage } from './highlight' + +/** How long typing pauses before the code is sent: CSS shows in the page well within 0.3 s (BO-11). */ +const SEND_AFTER_MS = 80 + +/** + * The text of one of a Tweak's code fields as typed. Edits go to main a moment after typing + * pauses; meanwhile, and until main has them, the field keeps what was typed. A change from + * elsewhere (a reset, the same Tweak in another window) shows once nothing is waiting. + */ +function useCode(id: string, field: 'css' | 'js', saved: string): [string, (value: string) => void] { + const [text, setText] = useState(saved) + const latest = useRef(saved) + const timer = useRef(undefined) + const sending = useRef(0) + + const flush = useCallback(() => { + if (timer.current === undefined) return + window.clearTimeout(timer.current) + timer.current = undefined + sending.current++ + void invoke('tweaks.update', { id, patch: { [field]: latest.current } }).finally(() => { + sending.current-- + }) + }, [id, field]) + + // Sent before the panel closes or runs the code, and as the panel goes. + useEffect(() => { + const release = holdEdits(flush) + return () => { + release() + flush() + } + }, [flush]) + + useEffect(() => { + if (timer.current !== undefined || sending.current > 0 || saved === latest.current) return + latest.current = saved + setText(saved) + }, [saved]) + + const change = (value: string) => { + latest.current = value + setText(value) + if (timer.current !== undefined) window.clearTimeout(timer.current) + timer.current = window.setTimeout(flush, SEND_AFTER_MS) + } + return [text, change] +} + +/** + * The code area (BO-11): the Tweak's own CSS, which applies as it is typed, and its JavaScript, + * which runs in the page each time a page of the site loads, where allowed on this device (BO-17, + * BO-18). "Run now" tries it at once; "Reload page" loads the page again. + */ +export function CodeArea({ tweak, state }: { tweak: Tweak; state: TweakEditorState }) { + const [language, setLanguage] = useState(() => (tweak.js.trim() && !tweak.css.trim() ? 'js' : 'css')) + const [css, setCss] = useCode(tweak.id, 'css', tweak.css) + const [js, setJs] = useCode(tweak.id, 'js', tweak.js) + const hasJs = !!js.trim() + const blockedHere = state.private && !tweak.jsInPrivate + const runs = hasJs && tweak.jsAllowed && !blockedHere + + const runNow = () => { + flushTweakEdits() + if (state.tabId) void invoke('tweaks.runNow', { id: tweak.id, tabId: state.tabId }) + } + const reload = () => { + flushTweakEdits() + if (state.tabId) void invoke('tabs.reload', { tabId: state.tabId }) + } + + return ( +
+
+ {(['css', 'js'] as const).map((id) => ( + + ))} +
+ {language === 'css' ? ( + + ) : ( + <> + + {hasJs && !tweak.jsAllowed && ( +
+ + This JavaScript came from elsewhere. It doesn't run until you allow it on this device. + +
+ )} +
+ + +
+ + + )} +
+ ) +} diff --git a/src/renderer/src/tweaks/CodeEditor.tsx b/src/renderer/src/tweaks/CodeEditor.tsx new file mode 100644 index 00000000..dcc6f8cb --- /dev/null +++ b/src/renderer/src/tweaks/CodeEditor.tsx @@ -0,0 +1,153 @@ +import { type KeyboardEvent, useLayoutEffect, useMemo, useRef } from 'react' +import { type CodeLanguage, highlight } from './highlight' + +/** One level of indentation. */ +const INDENT = ' ' +/** Past this many characters code shows without colours: colouring it on each key would slow typing. */ +const COLOUR_LIMIT = 30_000 + +interface Props { + value: string + onChange: (value: string) => void + language: CodeLanguage + label: string + testId: string + placeholder?: string + maxLength?: number +} + +/** + * A small code editor (BO-11): a plain text field over a coloured copy of its text, with line + * numbers beside. The field does the editing, so selection, the clipboard, IME input and ⌘Z / Ctrl+Z + * are the platform's own; Tab and ⇧Tab indent and outdent, Enter keeps the line's indentation. + * The coloured copy is text only, never markup. + */ +export function CodeEditor({ value, onChange, language, label, testId, placeholder, maxLength }: Props) { + const input = useRef(null) + const layer = useRef(null) + const gutter = useRef(null) + const tokens = useMemo(() => (value.length <= COLOUR_LIMIT ? highlight(value, language) : null), [value, language]) + const numbers = useMemo(() => { + let lines = 1 + for (let i = value.indexOf('\n'); i >= 0; i = value.indexOf('\n', i + 1)) lines++ + return Array.from({ length: lines }, (_, i) => i + 1).join('\n') + }, [value]) + + /** The coloured copy and the line numbers follow the field's scrolling. */ + const follow = () => { + const el = input.current + if (!el) return + if (layer.current) layer.current.style.transform = `translate(${-el.scrollLeft}px, ${-el.scrollTop}px)` + if (gutter.current) gutter.current.style.transform = `translateY(${-el.scrollTop}px)` + } + // Each change of the text may scroll the field. + useLayoutEffect(follow) + + const onKeyDown = (e: KeyboardEvent) => { + if (e.nativeEvent.isComposing || e.metaKey || e.ctrlKey || e.altKey) return + if (e.key === 'Tab') { + e.preventDefault() + indent(e.currentTarget, e.shiftKey) + } else if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + newLine(e.currentTarget) + } + } + + return ( +
+
+
+ {numbers} +
+
+
+ {tokens && ( +
+            {tokens.map((t, i) =>
+              t.kind === 'plain' ? (
+                t.text
+              ) : (
+                // biome-ignore lint/suspicious/noArrayIndexKey: tokens have no identity but their place
+                
+                  {t.text}
+                
+              ),
+            )}
+            {'\n'}
+          
+ )} +