Add enabled local routing rules and gateway version reporting
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-07-11 22:14:43 +03:00
parent a0c66edb02
commit 1304a22f1f
22 changed files with 585 additions and 188 deletions

View File

@@ -32,7 +32,7 @@ import {
import { HarborError, normalizeHarborError } from '../shared/errors.js';
import { normalizeRouteRules } from '../shared/routingRules.js';
import { createJsonStore, createStateStore } from './services/stateStore.js';
import { buildVersionInfo } from './version.js';
import { buildGatewayVersionInfo, buildVersionInfo } from './version.js';
const MAX_BODY_BYTES = 1_000_000;
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
@@ -90,7 +90,7 @@ let revision = normalizeStoredState(initialStoredState).revision;
function updateStoredState(update) {
return stateStore.update((stored) => {
const current = normalizeStoredState(stored);
const next = normalizeStoredState(update(current));
const next = normalizeStoredState({ schemaVersion: current.schemaVersion, ...update(current) });
revision = Math.max(revision, current.revision) + 1;
next.revision = revision;
return next;
@@ -505,11 +505,7 @@ async function handleApi(req, res) {
if (req.method === 'GET' && req.url === '/api/version') {
if (!remoteDataplane) return sendJson(res, 200, versionInfo);
const runtime = await singboxRuntime.refresh();
return sendJson(res, 200, {
...versionInfo,
components: { gatewayBackend: runtime.gatewayBackendVersion || null },
runtime: { singBox: runtime.singBoxVersion || null },
});
return sendJson(res, 200, buildGatewayVersionInfo(versionInfo, runtime));
}
if (req.method === 'GET' && req.url === '/api/shared-proxy') {

View File

@@ -2,8 +2,9 @@ import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { normalizeStoredState } from '../../shared/contracts/state.js';
import { INITIAL_ROUTE_RULES } from '../../shared/routingRules.js';
export const STATE_SCHEMA_VERSION = 2;
export const STATE_SCHEMA_VERSION = 3;
const clone = (value) => structuredClone(value);
const stamp = (value) => value.toISOString().replace(/[:.]/g, '-');
@@ -55,8 +56,11 @@ export function migrateStoredState(value) {
if (version < 0 || version > STATE_SCHEMA_VERSION) {
throw new Error(`Unsupported Harbor state schemaVersion: ${version}`);
}
const routeRules = version < 3
? [...INITIAL_ROUTE_RULES, ...(Array.isArray(stored.routeRules) ? stored.routeRules : [])]
: stored.routeRules;
return {
...normalizeStoredState(stored),
...normalizeStoredState({ ...stored, routeRules }),
schemaVersion: STATE_SCHEMA_VERSION,
};
}

View File

@@ -1,7 +1,7 @@
import fs from 'node:fs';
import { settings } from './config.js';
import { HarborError } from '../shared/errors.js';
import { BUILT_IN_DIRECT_RULES, normalizeRouteRules } from '../shared/routingRules.js';
import { normalizeRouteRules } from '../shared/routingRules.js';
import { atomicWriteFile, atomicWriteJson } from './services/stateStore.js';
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
@@ -52,10 +52,9 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, {
set_system_proxy: false,
},
];
const directRules = [...BUILT_IN_DIRECT_RULES, ...normalizeRouteRules(routeRules)].map((rule) => ({
[rule.type]: [rule.value],
outbound: 'direct',
}));
const directRules = normalizeRouteRules(routeRules)
.filter((rule) => rule.enabled)
.map((rule) => ({ [rule.type]: [rule.value], outbound: 'direct' }));
const rules = clientMode
? [...directRules, { inbound: [MIXED_INBOUND], outbound: outboundTag }]
: [

View File

@@ -18,3 +18,13 @@ export function buildVersionInfo(appMode, run = spawnSync) {
runtime: { singBox: detectSingBoxVersion(run) },
};
}
export function buildGatewayVersionInfo(controlInfo, dataplaneState) {
return {
...controlInfo,
runtime: {
dataplaneVersion: dataplaneState?.gatewayBackendVersion || null,
singBox: dataplaneState?.singBoxVersion || null,
},
};
}

View File

@@ -1,4 +1,4 @@
import { BUILT_IN_DIRECT_RULES, normalizeRouteRules } from '../routingRules.js';
import { normalizeRouteRules } from '../routingRules.js';
const MODES = new Set(['client', 'gateway']);
const CONNECTION_STATES = new Set(['running', 'stopped']);
@@ -83,10 +83,7 @@ export function createStateSnapshot({
gatewayAddress: mode === 'client' ? gatewayAuto?.gateway?.gateway || null : null,
lastVerifiedAt: null,
reason: mode === 'client' && stored.gatewayAutoEnabled !== false ? 'auto' : 'manual',
localRules: {
builtIn: BUILT_IN_DIRECT_RULES.map((rule) => ({ ...rule })),
custom: stored.routeRules,
},
localRules: stored.routeRules,
},
operation: {
kind: nullableText(operation.kind),
@@ -147,7 +144,8 @@ export function assertStateSnapshot(snapshot) {
rule &&
['domain', 'domain_suffix', 'domain_keyword'].includes(rule.type) &&
typeof rule.value === 'string' &&
Boolean(rule.value)
Boolean(rule.value) &&
typeof rule.enabled === 'boolean'
);
if (
@@ -177,11 +175,8 @@ export function assertStateSnapshot(snapshot) {
!nullableString(snapshot.route.gatewayAddress) ||
!nullableDate(snapshot.route.lastVerifiedAt) ||
typeof snapshot.route.reason !== 'string' ||
!snapshot.route.localRules ||
!Array.isArray(snapshot.route.localRules.builtIn) ||
!snapshot.route.localRules.builtIn.every(validRouteRule) ||
!Array.isArray(snapshot.route.localRules.custom) ||
!snapshot.route.localRules.custom.every(validRouteRule) ||
!Array.isArray(snapshot.route.localRules) ||
!snapshot.route.localRules.every(validRouteRule) ||
!snapshot.operation ||
!nullableString(snapshot.operation.kind) ||
!OPERATION_STATES.has(snapshot.operation.status) ||

View File

@@ -1,9 +1,9 @@
export const BUILT_IN_DIRECT_RULES = Object.freeze([
Object.freeze({ type: 'domain_suffix', value: 'ru' }),
export const INITIAL_ROUTE_RULES = Object.freeze([
Object.freeze({ type: 'domain_suffix', value: 'ru', enabled: true }),
]);
const RULE_TYPES = new Set(['domain', 'domain_suffix', 'domain_keyword']);
const MAX_RULES = 200;
export const MAX_ROUTE_RULES = 200;
function hostname(value) {
const input = String(value || '').trim().replace(/^\*\./, '').replace(/^\./, '');
@@ -17,13 +17,16 @@ function hostname(value) {
function normalizeRule(rule) {
const type = String(rule?.type || '').trim();
if (!RULE_TYPES.has(type)) throw new TypeError('Invalid domain rule type');
if (Object.hasOwn(rule || {}, 'enabled') && typeof rule.enabled !== 'boolean') {
throw new TypeError('Invalid domain rule enabled state');
}
const value = type === 'domain_keyword'
? String(rule?.value || '').trim().toLowerCase()
: hostname(rule?.value);
if (!value || value.length > 253 || /[\s/:?#]/.test(value)) {
throw new TypeError('Invalid domain rule value');
}
return { type, value };
return { type, value, enabled: rule?.enabled !== false };
}
export function normalizeRouteRules(value, { strict = false } = {}) {
@@ -31,13 +34,13 @@ export function normalizeRouteRules(value, { strict = false } = {}) {
if (strict) throw new TypeError('Route rules must be an array');
return [];
}
if (strict && value.length > MAX_RULES) throw new TypeError(`Route rules limit is ${MAX_RULES}`);
if (strict && value.length > MAX_ROUTE_RULES) {
throw new TypeError(`Route rules limit is ${MAX_ROUTE_RULES}`);
}
const seen = new Set(BUILT_IN_DIRECT_RULES.map(({ type, value: builtInValue }) => (
`${type}:${builtInValue}`
)));
const seen = new Set();
const normalized = [];
for (const candidate of value.slice(0, MAX_RULES)) {
for (const candidate of value.slice(0, MAX_ROUTE_RULES)) {
try {
const rule = normalizeRule(candidate);
const key = `${rule.type}:${rule.value}`;
@@ -50,3 +53,9 @@ export function normalizeRouteRules(value, { strict = false } = {}) {
}
return normalized;
}
export function canAppendRouteRule(rules) {
return Array.isArray(rules) &&
rules.length < MAX_ROUTE_RULES &&
rules.every((rule) => String(rule?.value || '').trim());
}

View File

@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.3.0',
gatewayClient: '0.3.0',
gatewayBackend: '0.3.0',
macClient: '0.4.0',
gatewayClient: '0.4.0',
gatewayBackend: '0.4.0',
});
export function parseVersion(value) {

View File

@@ -13,6 +13,7 @@ import {
import { formatBytes } from '../utils/format.js';
import { instructionBlocks } from '../instructions.js';
import { createLatestRequest, operationBlocked } from '../state/operations.js';
import { canAppendRouteRule } from '../../shared/routingRules.js';
import {
HARBOR_VERSIONS,
parseVersion,
@@ -32,7 +33,7 @@ const VERSION_PARTS = [
['hotfix', 'Hotfix'],
];
function VersionBadge({ code, component, componentKey, version, singBox, incompatible = false }) {
function VersionBadge({ code, component, componentKey, version, runtime, incompatible = false }) {
const parsed = parseVersion(version);
const values = parsed ? VERSION_PARTS.map(([key]) => parsed[key]) : ['', '', ''];
@@ -63,7 +64,7 @@ function VersionBadge({ code, component, componentKey, version, singBox, incompa
<span className="harbor-version-tooltip" id={tooltipId} role="tooltip">
<strong>{component} · {label} {values[index]}</strong>
<span>{description(key)}</span>
{singBox && <small>Runtime: sing-box {singBox}</small>}
{runtime && <small>{runtime}</small>}
{incompatible && <small className="is-warning">Версии Gateway несовместимы.</small>}
</span>
</span>
@@ -83,12 +84,13 @@ function VersionDisplay({ isGateway, versionInfo }) {
component="Mac client"
componentKey="macClient"
version={versionInfo?.components?.macClient || HARBOR_VERSIONS.macClient}
singBox={runtimeSingBox}
runtime={runtimeSingBox ? `Runtime: sing-box ${runtimeSingBox}` : null}
/>
</aside>;
}
const backendVersion = versionInfo?.components?.gatewayBackend;
const dataplaneVersion = versionInfo?.runtime?.dataplaneVersion;
const compatibility = backendVersion && versionCompatibility({
...HARBOR_VERSIONS,
gatewayBackend: backendVersion,
@@ -97,19 +99,25 @@ function VersionDisplay({ isGateway, versionInfo }) {
return <aside className="harbor-versions" aria-label="Версии Harbor Gateway">
<VersionBadge
code="C"
component="Gateway client"
component="Gateway client UI"
componentKey="gatewayClient"
version={HARBOR_VERSIONS.gatewayClient}
incompatible={incompatible}
/>
<VersionBadge
code="B"
component="Gateway backend"
component="Gateway control backend"
componentKey="gatewayBackend"
version={backendVersion}
singBox={runtimeSingBox}
incompatible={incompatible}
/>
<VersionBadge
code="D"
component="Gateway dataplane"
componentKey="gatewayDataplane"
version={dataplaneVersion}
runtime={runtimeSingBox ? `Runtime: sing-box ${runtimeSingBox}` : null}
/>
</aside>;
}
@@ -208,6 +216,106 @@ const ROUTE_RULE_PLACEHOLDERS = {
domain_keyword: 'cdn',
};
let localRuleDraftId = 0;
const createLocalRuleDraft = (rule) => ({
...rule,
enabled: rule?.enabled !== false,
_key: `route-rule-${localRuleDraftId += 1}`,
});
function RuleTypePicker({ value, ruleKey, index, disabled, onChange }) {
const [open, setOpen] = useState(false);
const rootRef = useRef(null);
const triggerRef = useRef(null);
const optionRefs = useRef([]);
const listId = `${ruleKey}-types`;
const selectedIndex = Math.max(0, ROUTE_RULE_OPTIONS.findIndex(([type]) => type === value));
useEffect(() => {
if (!open) return undefined;
optionRefs.current[selectedIndex]?.focus();
const close = (event) => {
if (event.type === 'keydown' && event.key !== 'Escape') return;
if (rootRef.current?.contains(event.target)) return;
setOpen(false);
};
document.addEventListener('pointerdown', close);
document.addEventListener('keydown', close);
return () => {
document.removeEventListener('pointerdown', close);
document.removeEventListener('keydown', close);
};
}, [open, selectedIndex]);
function choose(type) {
onChange(type);
setOpen(false);
triggerRef.current?.focus();
}
function moveOption(event, offset) {
if (!['ArrowDown', 'ArrowUp', 'Home', 'End', 'Escape'].includes(event.key)) return;
event.preventDefault();
if (event.key === 'Escape') {
setOpen(false);
triggerRef.current?.focus();
return;
}
const current = optionRefs.current.indexOf(document.activeElement);
const next = event.key === 'Home'
? 0
: event.key === 'End'
? ROUTE_RULE_OPTIONS.length - 1
: (current + offset + ROUTE_RULE_OPTIONS.length) % ROUTE_RULE_OPTIONS.length;
optionRefs.current[next]?.focus();
}
return (
<div className={`client-rule-type${open ? ' is-open' : ''}`} ref={rootRef}>
<button
ref={triggerRef}
className="client-rule-type-trigger"
type="button"
aria-label={`Тип правила ${index + 1}`}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listId}
disabled={disabled}
onClick={() => setOpen((current) => !current)}
onKeyDown={(event) => {
if (event.key === 'Escape' && open) {
event.preventDefault();
setOpen(false);
return;
}
if (!['ArrowDown', 'ArrowUp'].includes(event.key)) return;
event.preventDefault();
setOpen(true);
}}
>
<span>{ROUTE_RULE_OPTIONS[selectedIndex][1]}</span>
<svg viewBox="0 0 12 8" aria-hidden="true"><path d="m1 1 5 5 5-5" /></svg>
</button>
<div className="client-rule-type-list" id={listId} role="listbox" aria-hidden={!open}>
{ROUTE_RULE_OPTIONS.map(([type, label], optionIndex) => (
<button
ref={(node) => { optionRefs.current[optionIndex] = node; }}
type="button"
role="option"
aria-selected={type === value}
tabIndex={open ? 0 : -1}
key={type}
onClick={() => choose(type)}
onKeyDown={(event) => moveOption(event, event.key === 'ArrowUp' ? -1 : 1)}
>
{label}
</button>
))}
</div>
</div>
);
}
function LocalRulesPanel({
open,
rules,
@@ -218,9 +326,14 @@ function LocalRulesPanel({
onAdd,
onChange,
onRemove,
onRemoveComplete,
onClose,
onSave,
}) {
const activeRules = rules.filter((rule) => !rule.removing);
const canAdd = canAppendRouteRule(activeRules) && !blocked;
const incomplete = activeRules.some((rule) => !String(rule.value || '').trim());
return (
<aside
ref={panelRef}
@@ -238,29 +351,41 @@ function LocalRulesPanel({
</header>
<form className="client-local-rules-form" onSubmit={onSave}>
<section className="client-local-rules-group" aria-labelledby="built-in-rules-title">
<span id="built-in-rules-title">Базовое правило</span>
<div className="client-local-rule is-built-in">
<strong>Суффикс</strong>
<code>*.ru</code>
<small>Всегда напрямую</small>
</div>
</section>
<section className="client-local-rules-group" aria-labelledby="custom-rules-title">
<span id="custom-rules-title">Ваши правила</span>
<section className="client-local-rules-group" aria-labelledby="local-rules-list-title">
<span id="local-rules-list-title">Правила</span>
<div className="client-local-rules-list">
{rules.map((rule, index) => (
<div className="client-local-rule" key={index}>
<select
aria-label={`Тип правила ${index + 1}`}
value={rule.type}
onChange={(event) => onChange(index, 'type', event.target.value)}
<div
className={`client-local-rule${rule.enabled ? '' : ' is-disabled'}${rule.removing ? ' is-removing' : ''}`}
key={rule._key}
style={{ '--rule-index': index }}
inert={rule.removing ? true : undefined}
onAnimationEnd={(event) => {
if (event.animationName === 'client-local-rule-leave') {
onRemoveComplete(rule._key);
}
}}
>
<button
className="client-local-rule-enabled"
type="button"
role="switch"
aria-checked={rule.enabled}
aria-label={`${rule.enabled ? 'Выключить' : 'Включить'} правило ${index + 1}`}
onClick={() => onChange(index, 'enabled', !rule.enabled)}
>
{ROUTE_RULE_OPTIONS.map(([value, label]) => (
<option value={value} key={value}>{label}</option>
))}
</select>
<svg viewBox="0 0 20 20" aria-hidden="true">
<circle cx="10" cy="10" r="6" />
<path d="m6.8 10.1 2.1 2.2 4.5-5" />
</svg>
</button>
<RuleTypePicker
value={rule.type}
ruleKey={rule._key}
index={index}
disabled={rule.removing}
onChange={(type) => onChange(index, 'type', type)}
/>
<input
type="text"
inputMode="url"
@@ -281,9 +406,14 @@ function LocalRulesPanel({
</button>
</div>
))}
{!rules.length && <p className="client-local-rules-empty">Дополнительных исключений пока нет.</p>}
{!rules.length && <p className="client-local-rules-empty">Правил пока нет. Весь трафик идёт через VPN.</p>}
</div>
<div className="client-local-rule-add-slot">
<button className="client-local-rule-add" type="button" disabled={!canAdd} onClick={onAdd}>
+ Добавить правило
</button>
<span className={incomplete ? 'is-visible' : ''}>Сначала заполните текущее правило</span>
</div>
<button className="client-local-rule-add" type="button" onClick={onAdd}>+ Добавить правило</button>
</section>
<p className="client-local-rules-note">
@@ -776,7 +906,7 @@ export function ClientOverviewPage({
function openLocalRules() {
setInstructionsOpen(false);
setLocalRulesDraft((state?.route?.localRules?.custom || []).map((rule) => ({ ...rule })));
setLocalRulesDraft((state?.route?.localRules || []).map(createLocalRuleDraft));
setLocalRulesRevision(state?.revision || 0);
setLocalRulesOpen(true);
}
@@ -789,10 +919,23 @@ export function ClientOverviewPage({
async function saveLocalRules(event) {
event.preventDefault();
if (!await onSaveRouteRules(localRulesDraft, localRulesRevision)) return;
const rules = localRulesDraft
.filter((rule) => !rule.removing)
.map(({ type, value, enabled }) => ({ type, value, enabled }));
if (!await onSaveRouteRules(rules, localRulesRevision)) return;
setLocalRulesOpen(false);
}
function removeLocalRule(ruleKey) {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
setLocalRulesDraft((rules) => rules.filter((rule) => rule._key !== ruleKey));
return;
}
setLocalRulesDraft((rules) => rules.map((rule) => (
rule._key === ruleKey ? { ...rule, removing: true } : rule
)));
}
return (
<div className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}>
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
@@ -1211,13 +1354,19 @@ export function ClientOverviewPage({
{hasSubscription && subscriptionContentReady && <LocalRulesPanel
open={localRulesOpen}
rules={localRulesDraft}
blocked={routeRulesBlocked}
blocked={routeRulesBlocked || localRulesDraft.some((rule) => rule.removing)}
error={error}
operations={operations}
panelRef={localRulesPanelRef}
onAdd={() => setLocalRulesDraft((rules) => [...rules, { type: 'domain', value: '' }])}
onAdd={() => setLocalRulesDraft((rules) => [
...rules,
createLocalRuleDraft({ type: 'domain', value: '', enabled: true }),
])}
onChange={changeLocalRule}
onRemove={(index) => setLocalRulesDraft((rules) => rules.filter((_, ruleIndex) => ruleIndex !== index))}
onRemove={(index) => removeLocalRule(localRulesDraft[index]._key)}
onRemoveComplete={(ruleKey) => setLocalRulesDraft((rules) => (
rules.filter((rule) => rule._key !== ruleKey)
))}
onClose={() => setLocalRulesOpen(false)}
onSave={saveLocalRules}
/>}

View File

@@ -800,7 +800,6 @@ p {
overflow-y: auto;
background: color-mix(in oklch, var(--client-bg) 99%, var(--client-panel));
color: var(--client-text);
box-shadow: -26px 0 72px oklch(0.09 0.015 145 / 0.12);
opacity: 0;
visibility: hidden;
transform: translateX(104%);
@@ -862,100 +861,242 @@ p {
}
.client-local-rules-list {
gap: 8px;
gap: 2px;
}
.client-local-rule {
min-width: 0;
display: grid;
grid-template-columns: 112px minmax(0, 1fr) 32px;
grid-template-columns: 24px 116px minmax(0, 1fr) 28px;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 13px;
background: color-mix(in oklch, var(--client-panel) 56%, var(--client-bg));
box-shadow: 0 10px 28px oklch(0.1 0.015 145 / 0.055);
transition: background 240ms ease, box-shadow 320ms ease, transform 320ms cubic-bezier(0.16, 1, 0.3, 1);
gap: 10px;
padding: 7px 0;
animation: client-local-rule-enter 560ms calc(var(--rule-index) * 55ms) cubic-bezier(0.16, 1, 0.3, 1) both;
transition: opacity 260ms ease, filter 360ms ease;
}
.client-local-rule:focus-within {
background: color-mix(in oklch, var(--client-panel) 74%, var(--client-bg));
box-shadow: 0 14px 34px oklch(0.1 0.015 145 / 0.09);
transform: translateY(-1px);
.client-local-rule.is-disabled {
opacity: 0.42;
filter: saturate(0);
}
.client-local-rule.is-built-in {
grid-template-columns: 92px minmax(0, 1fr) auto;
color: var(--client-muted);
.client-local-rule.is-removing {
pointer-events: none;
animation: client-local-rule-leave 360ms cubic-bezier(0.7, 0, 0.84, 0) both;
}
.client-local-rule.is-built-in strong {
font-size: 10px;
font-weight: 700;
}
.client-local-rule.is-built-in code {
color: var(--client-accent);
font: 700 13px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
text-shadow: 0 0 12px color-mix(in oklch, var(--client-accent) 38%, transparent);
}
.client-local-rule.is-built-in small {
font-size: 8px;
white-space: nowrap;
}
.client-local-rule select,
.client-local-rule input {
min-width: 0;
height: 36px;
.client-local-rule-enabled {
width: 24px;
height: 32px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 8px;
outline: 0;
background: color-mix(in oklch, var(--client-control) 72%, transparent);
color: var(--client-text);
font-size: 10px;
}
.client-local-rule select {
padding: 0 7px;
background: transparent;
color: var(--client-muted);
cursor: pointer;
}
.client-local-rule-enabled svg {
width: 18px;
height: 18px;
overflow: visible;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
transition: color 260ms ease, filter 360ms ease, transform 420ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-local-rule-enabled circle {
stroke-width: 1.4;
transition: fill 320ms ease, stroke 260ms ease;
}
.client-local-rule-enabled path {
stroke-width: 1.8;
stroke-dasharray: 12;
stroke-dashoffset: 12;
transition: stroke-dashoffset 360ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-local-rule-enabled[aria-checked='true'] {
color: var(--client-accent);
}
.client-local-rule-enabled[aria-checked='true'] svg {
filter: drop-shadow(0 0 7px color-mix(in oklch, var(--client-accent) 48%, transparent));
transform: scale(1.08);
}
.client-local-rule-enabled[aria-checked='true'] circle {
fill: color-mix(in oklch, var(--client-accent) 12%, transparent);
}
.client-local-rule-enabled[aria-checked='true'] path {
stroke-dashoffset: 0;
}
.client-rule-type {
position: relative;
min-width: 0;
z-index: 1;
}
.client-rule-type.is-open {
z-index: 4;
}
.client-rule-type-trigger {
width: 100%;
height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0;
border: 0;
background: transparent;
color: var(--client-text);
font: 600 9px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
text-align: left;
cursor: pointer;
transition: color 220ms ease, text-shadow 300ms ease;
}
.client-rule-type-trigger svg {
width: 9px;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
transition: transform 420ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-rule-type.is-open .client-rule-type-trigger {
color: var(--client-accent);
text-shadow: 0 0 10px color-mix(in oklch, var(--client-accent) 44%, transparent);
}
.client-rule-type.is-open .client-rule-type-trigger svg {
transform: rotate(180deg);
}
.client-rule-type-list {
position: absolute;
top: calc(100% - 1px);
left: -8px;
z-index: 3;
width: max-content;
min-width: calc(100% + 16px);
display: grid;
gap: 1px;
padding: 7px 8px;
background: color-mix(in oklch, var(--client-bg) 91%, transparent);
-webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px);
opacity: 0;
visibility: hidden;
filter: blur(8px);
transform: translateY(-8px) scale(0.97);
transform-origin: top left;
pointer-events: none;
transition: opacity 200ms ease, filter 360ms ease, transform 420ms cubic-bezier(0.16, 1, 0.3, 1), visibility 0s 420ms;
}
.client-rule-type.is-open .client-rule-type-list {
opacity: 1;
visibility: visible;
filter: blur(0);
transform: translateY(0) scale(1);
pointer-events: auto;
transition-delay: 0s;
}
.client-rule-type-list button {
padding: 7px 6px;
border: 0;
background: transparent;
color: var(--client-muted);
font: 600 9px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
text-align: left;
white-space: nowrap;
cursor: pointer;
opacity: 0;
filter: blur(5px);
transform: translateY(-5px);
transition: color 180ms ease, opacity 260ms ease, filter 340ms ease, transform 380ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-rule-type.is-open .client-rule-type-list button {
opacity: 1;
filter: blur(0);
transform: translateY(0);
}
.client-rule-type.is-open .client-rule-type-list button:nth-child(2) {
transition-delay: 35ms;
}
.client-rule-type.is-open .client-rule-type-list button:nth-child(3) {
transition-delay: 70ms;
}
.client-rule-type-list button:hover,
.client-rule-type-list button:focus-visible,
.client-rule-type-list button[aria-selected='true'] {
outline: 0;
color: var(--client-accent);
text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 40%, transparent);
}
.client-local-rule input {
padding: 0 10px;
min-width: 0;
height: 34px;
padding: 0 2px;
border: 0;
outline: 0;
background: transparent;
color: var(--client-text);
font: 500 10px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
box-shadow: 0 1px 0 transparent;
transition: color 220ms ease, box-shadow 300ms ease, text-shadow 300ms ease;
}
.client-local-rule input::placeholder {
color: color-mix(in oklch, var(--client-muted) 72%, transparent);
color: color-mix(in oklch, var(--client-muted) 70%, transparent);
}
.client-local-rule select:focus-visible,
.client-local-rule input:focus-visible,
.client-local-rule button:focus-visible,
.client-local-rule-add:focus-visible,
.client-local-rules-actions button:focus-visible {
outline: 2px solid var(--client-accent);
outline-offset: 2px;
.client-local-rule input:focus {
box-shadow: 0 1px 0 color-mix(in oklch, var(--client-accent) 58%, transparent);
text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 22%, transparent);
}
.client-local-rule > button {
width: 32px;
.client-local-rule > button:last-child {
width: 28px;
height: 32px;
padding: 0;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--client-muted);
font-size: 17px;
cursor: pointer;
transition: color 180ms ease, background 180ms ease, transform 220ms ease;
transition: color 180ms ease, filter 260ms ease, transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-local-rule > button:hover {
background: color-mix(in oklch, oklch(0.68 0.15 28) 10%, transparent);
.client-local-rule > button:last-child:hover {
color: oklch(0.68 0.15 28);
transform: scale(1.08);
filter: drop-shadow(0 0 7px oklch(0.68 0.15 28 / 0.42));
transform: rotate(8deg) scale(1.1);
}
.client-local-rule-enabled:focus-visible,
.client-rule-type-trigger:focus-visible,
.client-local-rule > button:last-child:focus-visible,
.client-local-rule-add:focus-visible,
.client-local-rules-actions button:focus-visible {
outline: 0;
color: var(--client-accent);
text-shadow: 0 0 10px color-mix(in oklch, var(--client-accent) 48%, transparent);
}
.client-local-rules-empty {
@@ -974,16 +1115,43 @@ p {
font-size: 10px;
font-weight: 700;
cursor: pointer;
transition: opacity 220ms ease, color 220ms ease, text-shadow 300ms ease;
}
.client-local-rule-add:hover {
text-shadow: 0 0 10px color-mix(in oklch, var(--client-accent) 55%, transparent);
}
.client-local-rule-add:disabled {
opacity: 0.3;
cursor: not-allowed;
text-shadow: none;
}
.client-local-rule-add-slot {
min-height: 40px;
display: flex;
align-items: center;
gap: 12px;
}
.client-local-rule-add-slot > span {
color: var(--client-muted);
font-size: 8px;
opacity: 0;
filter: blur(5px);
transform: translateX(-6px);
transition: opacity 220ms ease, filter 320ms ease, transform 380ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-local-rule-add-slot > span.is-visible {
opacity: 1;
filter: blur(0);
transform: translateX(0);
}
.client-local-rules-note {
padding: 12px 14px;
border-radius: 12px;
background: color-mix(in oklch, var(--client-control) 44%, transparent);
max-width: 48ch;
color: var(--client-muted);
font-size: 9px;
line-height: 1.65;
@@ -1002,24 +1170,23 @@ p {
}
.client-local-rules-actions button {
padding: 10px 14px;
padding: 8px 0 8px 14px;
border: 0;
border-radius: 9px;
background: color-mix(in oklch, var(--client-control) 72%, transparent);
background: transparent;
color: var(--client-text);
font-size: 10px;
font-weight: 700;
cursor: pointer;
transition: background 200ms ease, color 200ms ease, transform 220ms ease;
transition: color 200ms ease, filter 300ms ease, transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-local-rules-actions button:hover:not(:disabled) {
background: var(--client-control);
color: var(--client-accent);
filter: drop-shadow(0 0 7px color-mix(in oklch, var(--client-accent) 40%, transparent));
transform: translateY(-1px);
}
.client-local-rules-actions .is-primary {
background: color-mix(in oklch, var(--client-accent) 18%, var(--client-control));
color: var(--client-accent);
}
@@ -1028,6 +1195,16 @@ p {
cursor: wait;
}
@keyframes client-local-rule-enter {
from { opacity: 0; filter: blur(8px); transform: translateY(-10px) scale(0.985); }
to { opacity: 1; filter: blur(0); transform: translateY(0) scale(1); }
}
@keyframes client-local-rule-leave {
from { opacity: 1; filter: blur(0); transform: translateX(0) scale(1); }
to { opacity: 0; filter: blur(8px); transform: translateX(18px) scale(0.97); }
}
.client-instructions {
position: fixed;
inset: 0 auto 0 0;
@@ -2524,25 +2701,29 @@ p {
font-size: 18px;
}
.client-local-rule,
.client-local-rule.is-built-in {
grid-template-columns: 1fr 32px;
.client-local-rule {
grid-template-columns: 24px minmax(0, 1fr) 28px;
gap: 8px;
}
.client-local-rule select,
.client-local-rule.is-built-in strong {
grid-column: 1 / -1;
}
.client-local-rule input,
.client-local-rule.is-built-in code {
.client-local-rule-enabled {
grid-column: 1;
grid-row: 1;
}
.client-local-rule.is-built-in small {
.client-rule-type {
grid-column: 2;
grid-row: 1;
}
.client-local-rule > button:last-child {
grid-column: 3;
grid-row: 1;
}
.client-local-rule input {
grid-column: 2 / -1;
grid-row: 2;
white-space: normal;
}
.client-instructions-header h2 {
@@ -2609,7 +2790,17 @@ p {
.client-local-rules-toggle svg,
.client-local-rules-toggle span,
.client-local-rule,
.client-local-rule > button,
.client-local-rule-enabled svg,
.client-local-rule-enabled circle,
.client-local-rule-enabled path,
.client-rule-type-trigger,
.client-rule-type-trigger svg,
.client-rule-type-list,
.client-rule-type-list button,
.client-local-rule input,
.client-local-rule > button:last-child,
.client-local-rule-add,
.client-local-rule-add-slot > span,
.client-local-rules-actions button {
transition: none;
animation: none;