Add Netx Ops Plugins settings card for URL and token.

Ship the browser half so Settings → Plugins can configure apiUrl/lang/python and NETX_API_TOKEN without OS env vars.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-04 22:16:14 +08:00
parent 45922ffd29
commit fd1a86ec2f
14 changed files with 1492 additions and 57 deletions

184
src/client/NetxopsCard.tsx Normal file
View file

@ -0,0 +1,184 @@
/**
* Netx Ops Plugins settings card — apiUrl / lang / python + credential token.
*/
import { useEffect, useRef, useState } from 'react'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { NetxopsCardFace, NetxopsCardState } from './controller.ts'
import type { NetxopsLocaleKey } from './locales.ts'
import type { CardFieldState } from './card-form.ts'
import { ensureStyles } from './styles.ts'
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
export type NetxopsCardProps =
PropsRuntime<'settings.plugin.item'>
& PropsLocale<'settings.netxops'>
& InjectFace<NetxopsCardFace>
function ValueField(props: {
id: string
label: string
hint: string
field: CardFieldState
overriddenLabel: string
resetLabel: string
invalidLabel: string
disabled: boolean
onEdit: (text: string) => void
onReset: () => void
}) {
return (
<div className="dsh-nx-field">
<div className="dsh-nx-fieldHead">
<label className="dsh-nx-label" htmlFor={props.id}>{props.label}</label>
{props.field.overridden
? (
<span className="dsh-nx-badges">
<span className="dsh-nx-badge">{props.overriddenLabel}</span>
<button type="button" className="dsh-nx-reset" disabled={props.disabled} onClick={props.onReset}>
{props.resetLabel}
</button>
</span>
)
: null}
</div>
<input
id={props.id}
className={props.field.invalid ? 'dsh-nx-input dsh-nx-inputInvalid' : 'dsh-nx-input'}
type="text"
value={props.field.text}
disabled={props.disabled}
aria-invalid={props.field.invalid || undefined}
onChange={(event) => { props.onEdit(event.target.value) }}
/>
<p className={props.field.invalid ? 'dsh-nx-invalid' : 'dsh-nx-hint'}>
{props.field.invalid ? props.invalidLabel : props.hint}
</p>
</div>
)
}
export function NetxopsCard(props: NetxopsCardProps) {
ensureStyles()
const { t } = props
const state: NetxopsCardState = props.useNetxopsCard(snapshot => snapshot)
const [open, setOpen] = useState(false)
const saveStarted = useRef(false)
useEffect(() => {
if (state.saving) {
saveStarted.current = true
return
}
if (!saveStarted.current) return
saveStarted.current = false
if (!state.dirty && !state.failed) setOpen(false)
}, [state.dirty, state.failed, state.saving])
if (!state.available) return null
const title = t('title' satisfies NetxopsLocaleKey)
const disabled = !state.writable
const blocked = !state.dirty || state.invalid || state.saving
return (
<li className={open ? 'dsh-nx-card dsh-nx-cardOpen' : 'dsh-nx-card'}>
<button
type="button"
className="dsh-nx-header"
aria-expanded={open}
aria-label={`${t(open ? 'collapse' : 'expand')}: ${title}`}
onClick={() => { setOpen(!open) }}
>
<span className="dsh-nx-headText">
<span className="dsh-nx-name">{title}</span>
<span className="dsh-nx-desc">{t('description')}</span>
</span>
{state.dirty ? <span className="dsh-nx-pending">{t('unsaved')}</span> : null}
<span className={open ? 'dsh-nx-chevron dsh-nx-chevronOpen' : 'dsh-nx-chevron'} aria-hidden>▾</span>
</button>
{open
? (
<div className="dsh-nx-body">
{!state.writable ? <p className="dsh-nx-readOnly" role="status">{t('readOnly')}</p> : null}
<div className="dsh-nx-field">
<div className="dsh-nx-fieldHead">
<label className="dsh-nx-label" htmlFor="netxops-api-token">{t('apiToken')}</label>
<span className="dsh-nx-badges">
<span className={state.apiTokenConfigured ? 'dsh-nx-badge' : 'dsh-nx-badgeMuted'}>
{state.apiTokenConfigured ? t('apiTokenSet') : t('apiTokenUnset')}
</span>
</span>
</div>
<input
id="netxops-api-token"
className="dsh-nx-input"
type="password"
autoComplete="off"
value={state.apiToken.text}
disabled={!state.apiTokenWritable}
onChange={(event) => { props.edit('apiToken', event.target.value) }}
/>
<p className="dsh-nx-hint">{t('apiTokenHint')}</p>
</div>
<ValueField
id="netxops-api-url"
label={t('apiUrl')}
hint={t('apiUrlHint')}
field={state.apiUrl}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
invalidLabel={t('invalid')}
disabled={disabled}
onEdit={(text) => { props.edit('apiUrl', text) }}
onReset={() => { props.resetField('apiUrl') }}
/>
<ValueField
id="netxops-lang"
label={t('lang')}
hint={t('langHint')}
field={state.lang}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
invalidLabel={t('invalid')}
disabled={disabled}
onEdit={(text) => { props.edit('lang', text) }}
onReset={() => { props.resetField('lang') }}
/>
<ValueField
id="netxops-python"
label={t('pythonCommand')}
hint={t('pythonCommandHint')}
field={state.pythonCommand}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
invalidLabel={t('invalid')}
disabled={disabled}
onEdit={(text) => { props.edit('pythonCommand', text) }}
onReset={() => { props.resetField('pythonCommand') }}
/>
<div className="dsh-nx-footer">
{state.failed ? <p className="dsh-nx-failed" role="status">{t('saveFailed')}</p> : null}
<button
type="button"
className="dsh-nx-btn dsh-nx-discard"
disabled={!state.dirty || state.saving}
onClick={props.discard}
>
{t('discard')}
</button>
<button
type="button"
className="dsh-nx-btn dsh-nx-save"
disabled={blocked}
onClick={props.save}
>
{t(state.saving ? 'saving' : 'save')}
</button>
</div>
</div>
)
: null}
</li>
)
}

221
src/client/card-form.ts Normal file
View file

@ -0,0 +1,221 @@
/**
* Shared staged-form model for the Netx Ops Plugins settings card.
* Self-contained (cannot value-import ui-settings-plugins — client bundle purity).
*/
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
export type FieldWrite = { kind: 'set'; value: unknown } | { kind: 'clear' }
export interface CardFieldSpec {
field: string
format: (value: unknown) => string
parse: (text: string) => FieldWrite | undefined
}
export interface CardSecretSpec {
field: string
write: (text: string) => Promise<boolean>
}
export interface CardFieldState {
text: string
overridden: boolean
invalid: boolean
}
export interface CardShell {
available: boolean
writable: boolean
dirty: boolean
invalid: boolean
saving: boolean
failed: boolean
}
export interface CardActions {
edit: (field: string, text: string) => void
resetField: (field: string) => void
save: () => void
discard: () => void
}
interface StagedEdit {
text: string
clear: boolean
}
interface PlannedWrite {
field: string
run: (() => Promise<boolean>) | undefined
}
export function textField(field: string): CardFieldSpec {
return {
field,
format: value => typeof value === 'string' ? value : '',
parse: (text) => {
const trimmed = text.trim()
return trimmed === '' ? { kind: 'clear' } : { kind: 'set', value: trimmed }
},
}
}
export class CardForm<T> {
private readonly specs: Map<string, CardFieldSpec>
private readonly secretSpecs: Map<string, CardSecretSpec>
private readonly staged = new Map<string, StagedEdit>()
private readonly listeners = new Set<() => void>()
private saving = false
private failed = false
constructor(
private readonly scope: SettingsScope<T>,
specs: CardFieldSpec[],
secrets: CardSecretSpec[] = [],
) {
this.specs = new Map(specs.map(spec => [spec.field, spec]))
this.secretSpecs = new Map(secrets.map(spec => [spec.field, spec]))
scope.subscribe(() => { this.publish() })
}
bind<S>(project: () => S): SnapshotStore<S> {
const store = createSnapshotStore(project())
this.listeners.add(() => { store.set(project()) })
return store
}
shell(): CardShell {
const snapshot = this.scope.getSnapshot()
const plan = this.plan()
return {
available: snapshot.status === 'ready',
writable: snapshot.writable,
dirty: plan.length > 0,
invalid: plan.some(item => item.run === undefined),
saving: this.saving,
failed: this.failed,
}
}
field(field: string): CardFieldState {
const staged = this.staged.get(field)
if (this.secretSpecs.has(field)) {
return { text: staged?.text ?? '', overridden: false, invalid: false }
}
const spec = this.spec(field)
if (staged === undefined) {
return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false }
}
const write = staged.clear ? { kind: 'clear' as const } : spec.parse(staged.text)
return {
text: staged.text,
overridden: write?.kind === 'set',
invalid: write === undefined,
}
}
actions(): CardActions {
return {
edit: (field, text) => { this.stage(field, { text, clear: false }) },
resetField: (field) => {
this.stage(field, { text: this.spec(field).format(this.baseValue(field)), clear: true })
},
save: () => { void this.save() },
discard: () => {
if (this.staged.size === 0 && !this.failed) return
this.staged.clear()
this.failed = false
this.publish()
},
}
}
async save(): Promise<void> {
const plan = this.plan()
const writes = plan.flatMap(item => item.run === undefined ? [] : [item.run])
if (plan.length === 0 || this.saving || writes.length !== plan.length) return
this.saving = true
this.failed = false
this.publish()
let landed = true
for (const write of writes) {
landed = await write() && landed
}
if (landed) this.staged.clear()
this.saving = false
this.failed = !landed
this.publish()
}
private plan(): PlannedWrite[] {
const plan: PlannedWrite[] = []
for (const [field, staged] of this.staged) {
const secret = this.secretSpecs.get(field)
if (secret !== undefined) {
const value = staged.text.trim()
if (value !== '') plan.push({ field, run: () => secret.write(value) })
continue
}
const spec = this.spec(field)
if (staged.clear) {
if (this.stored(field)) plan.push({ field, run: () => this.clear(field) })
continue
}
if (staged.text === spec.format(this.sectionValue(field))) continue
const write = spec.parse(staged.text)
if (write === undefined) plan.push({ field, run: undefined })
else if (write.kind === 'clear') plan.push({ field, run: () => this.clear(field) })
else plan.push({ field, run: () => this.store(field, write.value) })
}
return plan
}
private async clear(field: string): Promise<boolean> {
await this.scope.unset(field)
return !this.stored(field)
}
private async store(field: string, value: unknown): Promise<boolean> {
await this.scope.set(field, value)
return this.userLayer()?.[field] === value
}
private stage(field: string, edit: StagedEdit): void {
this.staged.set(field, edit)
this.failed = false
this.publish()
}
private spec(field: string): CardFieldSpec {
const spec = this.specs.get(field)
if (spec === undefined) throw new Error(`netxops card has no field ${field}`)
return spec
}
private snapshotOf(): SettingsScopeSnapshot<T> {
return this.scope.getSnapshot()
}
private sectionValue(field: string): unknown {
return (this.snapshotOf().value as Record<string, unknown> | undefined)?.[field]
}
private baseValue(field: string): unknown {
return (this.snapshotOf().base as Record<string, unknown> | undefined)?.[field]
}
private userLayer(): Record<string, unknown> | undefined {
return this.snapshotOf().user as Record<string, unknown> | undefined
}
private stored(field: string): boolean {
const user = this.userLayer()
return user !== undefined && Object.hasOwn(user, field)
}
private publish(): void {
for (const listener of this.listeners) listener()
}
}

115
src/client/controller.ts Normal file
View file

@ -0,0 +1,115 @@
/**
* Staged form over settings namespace `netxops` + credential NETX_API_TOKEN.
*/
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
import {
CardForm, textField,
type CardActions, type CardFieldState, type CardShell,
} from './card-form.ts'
export const NETXOPS_NS = 'netxops'
const DEFAULT_TOKEN_REF = 'NETX_API_TOKEN'
const API_TOKEN_FIELD = 'apiToken'
export interface NetxopsSettings {
apiUrl?: string
lang?: string
pythonCommand?: string
tokenCredentialRef?: string
}
interface CredentialState {
ref: string
configured: boolean
writable: boolean
}
export interface NetxopsCardState extends CardShell {
apiUrl: CardFieldState
lang: CardFieldState
pythonCommand: CardFieldState
apiToken: CardFieldState
apiTokenConfigured: boolean
apiTokenWritable: boolean
}
export interface NetxopsCardFace extends CardActions {
hooks: {
netxopsCard: SnapshotStore<NetxopsCardState>
}
}
export class NetxopsCardController {
private readonly form: CardForm<NetxopsSettings>
private readonly store: SnapshotStore<NetxopsCardState>
private credential: CredentialState = { ref: '', configured: false, writable: true }
constructor(
private readonly scope: SettingsScope<NetxopsSettings>,
private readonly ctx: ClientContext,
) {
this.form = new CardForm(
scope,
[textField('apiUrl'), textField('lang'), textField('pythonCommand')],
[{ field: API_TOKEN_FIELD, write: text => this.writeToken(text) }],
)
this.store = this.form.bind(() => this.projection())
scope.subscribe(() => { void this.readCredential() })
void this.readCredential()
}
private projection(): NetxopsCardState {
return {
...this.form.shell(),
apiUrl: this.form.field('apiUrl'),
lang: this.form.field('lang'),
pythonCommand: this.form.field('pythonCommand'),
apiToken: this.form.field(API_TOKEN_FIELD),
apiTokenConfigured: this.credential.configured,
apiTokenWritable: this.credential.writable,
}
}
private async readCredential(): Promise<void> {
const ref = refOf(this.scope.getSnapshot())
if (ref !== this.credential.ref) {
this.credential = { ref, configured: false, writable: true }
this.store.set(this.projection())
}
const response = await this.ctx.remote.credentials.describe([ref])
if (!response.ok || ref !== refOf(this.scope.getSnapshot())) return
const view = response.value[ref]
const next: CredentialState = {
ref,
configured: view?.configured ?? false,
writable: view?.writable ?? true,
}
if (next.configured === this.credential.configured && next.writable === this.credential.writable) return
this.credential = next
this.store.set(this.projection())
}
refreshCredential(ref: string): void {
if (ref !== this.credential.ref) return
void this.readCredential()
}
inject(): NetxopsCardFace {
return { hooks: { netxopsCard: this.store }, ...this.form.actions() }
}
private async writeToken(value: string): Promise<boolean> {
await this.ctx.remote.credentials.set(refOf(this.scope.getSnapshot()), value)
await this.readCredential()
return this.credential.configured
}
}
function refOf(snapshot: SettingsScopeSnapshot<NetxopsSettings>): string {
const declared = snapshot.value?.tokenCredentialRef
return declared !== undefined && declared.length > 0 ? declared : DEFAULT_TOKEN_REF
}

50
src/client/index.ts Normal file
View file

@ -0,0 +1,50 @@
/**
* Browser half — Settings → Plugins → Netx Ops card.
*/
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
import { NetxopsCard } from './NetxopsCard.tsx'
import { NETXOPS_NS, NetxopsCardController } from './controller.ts'
import { en, zh, type NetxopsLocaleKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
'settings.netxops': NetxopsLocaleKey
}
}
const LOCALE_NS = 'settings.netxops'
export const inject = [
'slots',
'locale',
'remote',
'remote.credentials',
'settingsScope',
]
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(LOCALE_NS, { zh, en }), 'netxops: locales')
const card = new NetxopsCardController(
ctx.settingsScope.bind({ namespace: NETXOPS_NS }),
ctx,
)
ctx.effect(
() => ctx.remote.$on('credentials/reference-updated', (ref) => { card.refreshCredential(ref) }),
'netxops: credential invalidations',
)
ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
name: 'settings.plugin.item',
key: NETXOPS_NS,
locale: LOCALE_NS,
inject: () => card.inject(),
}, NetxopsCard))
}

78
src/client/locales.ts Normal file
View file

@ -0,0 +1,78 @@
/** Locale keys for the Netx Ops Plugins settings card. */
export type NetxopsLocaleKey =
| 'title'
| 'description'
| 'apiUrl'
| 'apiUrlHint'
| 'lang'
| 'langHint'
| 'pythonCommand'
| 'pythonCommandHint'
| 'apiToken'
| 'apiTokenHint'
| 'apiTokenSet'
| 'apiTokenUnset'
| 'overridden'
| 'reset'
| 'invalid'
| 'expand'
| 'collapse'
| 'unsaved'
| 'readOnly'
| 'saveFailed'
| 'discard'
| 'save'
| 'saving'
export const en: Record<NetxopsLocaleKey, string> = {
title: 'Netx Ops',
description: 'UME API endpoint and bearer token for mcp__netx__* tools.',
apiUrl: 'API URL',
apiUrlHint: 'netx REST root, e.g. http://127.0.0.1:8890',
lang: 'Language',
langHint: 'Passed as NETX_LANG (zh / en).',
pythonCommand: 'Python command',
pythonCommandHint: 'Executable that can run `python -m netx_mcp`.',
apiToken: 'API token',
apiTokenHint: 'Stored as credential NETX_API_TOKEN (never written into settings). Leave blank to keep the current token.',
apiTokenSet: 'Configured',
apiTokenUnset: 'Not set',
overridden: 'Overridden',
reset: 'Reset',
invalid: 'Invalid value',
expand: 'Expand',
collapse: 'Collapse',
unsaved: 'Unsaved',
readOnly: 'This document is read-only.',
saveFailed: 'Save failed — drafts kept for correction.',
discard: 'Discard',
save: 'Save',
saving: 'Saving…',
}
export const zh: Record<NetxopsLocaleKey, string> = {
title: 'Netx Ops',
description: 'UME API 地址与 Bearer Token,供 mcp__netx__* 工具使用。',
apiUrl: 'API 地址',
apiUrlHint: 'netx REST 根地址,例如 http://127.0.0.1:8890',
lang: '语言',
langHint: '传给 MCP 的 NETX_LANG(zh / en)。',
pythonCommand: 'Python 命令',
pythonCommandHint: '能执行 `python -m netx_mcp` 的解释器。',
apiToken: 'API Token',
apiTokenHint: '写入凭据 NETX_API_TOKEN(不会进 settings)。留空表示保留已有 token。',
apiTokenSet: '已配置',
apiTokenUnset: '未设置',
overridden: '已覆盖',
reset: '重置',
invalid: '无效值',
expand: '展开',
collapse: '收起',
unsaved: '未保存',
readOnly: '当前文档只读。',
saveFailed: '保存失败,草稿已保留以便修改。',
discard: '丢弃',
save: '保存',
saving: '保存中…',
}

49
src/client/styles.ts Normal file
View file

@ -0,0 +1,49 @@
/** Inject shared Netx Ops card styles once. */
const STYLE_ID = 'dsh-netxops-card-css'
const CSS = `
.dsh-nx-card{list-style:none;border:.5px solid var(--dsw-alias-border-l4);border-radius:16px;background:var(--dsw-alias-bg-layer-3);transition:border-color .16s,background .16s}
.dsh-nx-card:hover{border-color:var(--dsw-alias-label-dimmed)}
.dsh-nx-cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}
.dsh-nx-header{width:100%;appearance:none;border:0;background:none;font:inherit;color:inherit;text-align:left;cursor:pointer;display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:12px}
.dsh-nx-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
.dsh-nx-headText{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}
.dsh-nx-name{font-size:15px;font-weight:600;line-height:1.4;color:var(--dsw-alias-label-primary)}
.dsh-nx-desc{font-size:13px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
.dsh-nx-pending{flex:none;border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;font-weight:500;white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary)}
.dsh-nx-chevron{flex:none;color:var(--dsw-alias-label-tertiary);transition:transform .16s}
.dsh-nx-chevronOpen{transform:rotate(180deg)}
.dsh-nx-body{border-top:.5px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}
.dsh-nx-readOnly{margin:12px 0 0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
.dsh-nx-field{display:flex;flex-direction:column;gap:6px;padding:12px 0}
.dsh-nx-field+.dsh-nx-field{border-top:.5px solid var(--dsw-alias-border-l2)}
.dsh-nx-fieldHead{display:flex;align-items:center;gap:8px}
.dsh-nx-label{flex:1;min-width:0;font-size:13px;font-weight:500;line-height:1.5;color:var(--dsw-alias-label-primary)}
.dsh-nx-badges{display:inline-flex;align-items:center;gap:8px}
.dsh-nx-badge{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;white-space:nowrap;font-weight:500;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary)}
.dsh-nx-badgeMuted{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;white-space:nowrap;color:var(--dsw-alias-label-tertiary)}
.dsh-nx-reset{border:none;background:none;padding:0;font:inherit;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer}
.dsh-nx-reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}
.dsh-nx-input{height:34px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:var(--dsw-alias-bg-layer-3);font:inherit;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-primary)}
.dsh-nx-input:focus-visible{outline:none;border-color:var(--dsw-alias-brand-primary)}
.dsh-nx-inputInvalid{border-color:var(--dsw-alias-label-error)}
.dsh-nx-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
.dsh-nx-invalid{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
.dsh-nx-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:12px 0 4px;border-top:.5px solid var(--dsw-alias-border-l2)}
.dsh-nx-failed{flex:1;min-width:0;margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
.dsh-nx-btn{appearance:none;border:1px solid transparent;border-radius:8px;padding:5px 14px;font:inherit;font-size:13px;line-height:1.5;cursor:pointer}
.dsh-nx-discard{border-color:var(--dsw-alias-border-l2);background:none;color:var(--dsw-alias-label-secondary)}
.dsh-nx-discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}
.dsh-nx-save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}
.dsh-nx-btn:disabled{opacity:.4;cursor:default}
`
export function ensureStyles(): void {
if (typeof document === 'undefined') return
if (document.getElementById(STYLE_ID)) return
const tag = document.createElement('style')
tag.id = STYLE_ID
tag.textContent = CSS
document.head.appendChild(tag)
}