12 tasks: vitest setup, semantic theme tokens, four in-repo primitives (ProgressBar/AppButton/Popover/Icon) with unit tests, per-component restyle, and element-plus removal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
44 KiB
Slate GUI Redesign Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Restyle the n-link desktop app with a dark-first "Slate" visual language (semantic CSS-variable theming, light/dark via OS preference, Inter type) while keeping the existing three-pane workflow unchanged.
Architecture: Introduce a semantic color-token layer as CSS custom properties mapped into Tailwind, then migrate every hardcoded color utility to a token. Replace Element-Plus's ElPopover (its only usage) and duplicated button/progress/icon markup with four small in-repo components (Popover, AppButton, ProgressBar, Icon) that carry real logic and get unit tests. The remaining work is per-component restyle verified by build + visual check.
Tech Stack: Vue 3 (<script setup>), Vite 6, Tailwind 3, Tauri 2, SCSS. New: Vitest + @vue/test-utils + happy-dom for the logic-bearing primitives; @fontsource/inter for bundled typography.
Global Constraints
- No workflow/layout changes. Panes, breadcrumbs, and flow stay as they are today. Visual restyle only.
- No changes to Rust, the vendored
libnspire, ornlink-cli. - Dark is the default/fallback theme. The app follows the OS
prefers-color-scheme; when unknown it renders dark. - Semantic tokens only. After this work, no component may use a hardcoded palette color (
bg-blue-500,text-gray-700,#4d88e8,bg-teal-400, etc.) or a bareborderutility for themed borders — use the token utilities defined in Task 2. - No
element-plusimport may remain anywhere undern-link-core/ordesktop/when the plan is complete. - Token utility names (defined in Task 2, used everywhere after):
bg-surface,bg-raised,border-edge,text-fg,text-muted,bg-accent/text-accent/text-accent-fg,bg-selected,bg-ok,bg-warn,bg-danger,bg-success. - All new components live in
n-link-core/components/and are imported by desktop views via the existingn-link-core/...path alias. - Feather icons already ship with
stroke="currentColor"; the only fix needed is rendering them inline (not via<img>) so they inherit theme color. - Visual verification: run
npm run devindesktop/and openhttp://localhost:1420in a browser (device features need the full Tauri build, but layout/theme render in the plain browser). Do not rely onnpm run tauri devfor visual checks in this environment — it fails to launch under snap VS Code (a known env issue, not a code issue).
File Structure
New files:
n-link-core/components/ProgressBar.vue— themed progress bar; one responsibility: render a 0–1 fraction as a filled track in a token color.n-link-core/components/AppButton.vue— themed button with variants; replaces duplicated.buttonSCSS.n-link-core/components/Popover.vue— headless anchored popover with outside-click / Escape close; replacesel-popover.n-link-core/components/Icon.vue— inline feather-icon renderer that inheritscurrentColor.n-link-core/components/*.spec.ts— unit tests colocated with the four primitives.desktop/vitest.config.ts— Vitest config (happy-dom, vue plugin,n-link-corealias, cross-package include).
Modified files:
n-link-core/assets/tailwind.css— token definitions (dark default + light), remove dead.el-popoverrule.n-link-core/tailwind.config.js— semantic token colors, Inter infontFamily.sans, remove deadui.*palette.n-link-core/package.json— addfeather-icons; dropelement-pluspeer dep.desktop/package.json— addvitest,@vue/test-utils,happy-dom,@fontsource/inter,testscript; dropelement-plus.desktop/src/main.ts— import Inter font CSS.desktop/src/App.vue— app-shell base background/text/font from tokens.desktop/src/views/Home.vue— token migration for the device sidebar + spinner.n-link-core/components/DeviceSelect.vue— Popover + Icon + tokens.n-link-core/components/CalcInfo.vue— AppButton + ProgressBar + tokens.n-link-core/components/DeviceQueue.vue— Popover + ProgressBar + Icon + tokens.n-link-core/components/FileData.vue— Popover + AppButton + tokens.n-link-core/components/FileBrowser.vue— breadcrumb + detail-rail tokens, Icon.n-link-core/components/FileView.vue— selection/hover tokens.
FileIcon.vue is intentionally not changed: it renders multicolor file-type glyphs that must keep their own colors, so currentColor inheritance does not apply.
Task 1: Test infrastructure
Files:
- Create:
desktop/vitest.config.ts - Modify:
desktop/package.json - Test:
n-link-core/components/smoke.spec.ts(temporary, deleted in Step 6)
Interfaces:
-
Produces: a working
npm test(aliasvitest run) indesktop/that discovers*.spec.tsundern-link-core/components/and resolves.vuesingle-file components. -
Step 1: Add dev dependencies and test script
In desktop/package.json, add to devDependencies:
"@vue/test-utils": "^2.4.6",
"happy-dom": "^15.11.7",
"vitest": "^2.1.8"
Add to scripts:
"test": "vitest run"
Then run: npm install (from repo root, workspaces are configured).
- Step 2: Write the Vitest config
Create desktop/vitest.config.ts:
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
import { fileURLToPath, URL } from 'node:url';
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'n-link-core': fileURLToPath(new URL('../n-link-core', import.meta.url)),
},
},
test: {
environment: 'happy-dom',
include: ['../n-link-core/components/**/*.spec.ts', 'src/**/*.spec.ts'],
},
});
- Step 3: Write a smoke test that mounts a trivial SFC
Create n-link-core/components/smoke.spec.ts:
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { defineComponent, h } from 'vue';
describe('vitest setup', () => {
it('mounts a component', () => {
const C = defineComponent({ render: () => h('div', 'hello') });
const wrapper = mount(C);
expect(wrapper.text()).toBe('hello');
});
});
- Step 4: Run the smoke test
Run: cd desktop && npm test
Expected: PASS — 1 passed, prints "hello" test green.
- Step 5: Commit
git add desktop/package.json desktop/vitest.config.ts package-lock.json n-link-core/components/smoke.spec.ts
git commit -m "test: set up vitest for n-link-core components"
- Step 6: Remove the smoke test and commit
git rm n-link-core/components/smoke.spec.ts
git commit -m "test: drop vitest smoke test"
Task 2: Semantic theme tokens
Files:
- Modify:
n-link-core/assets/tailwind.css - Modify:
n-link-core/tailwind.config.js - Modify:
desktop/package.json - Modify:
desktop/src/main.ts - Modify:
desktop/src/App.vue
Interfaces:
-
Produces: the token utility classes listed in Global Constraints, backed by CSS variables that switch on OS theme. Every later task consumes these.
-
Step 1: Add the Inter font dependency
In desktop/package.json dependencies, add:
"@fontsource/inter": "^5.1.0"
Run: npm install from repo root.
- Step 2: Define the token variables in the global stylesheet
Replace the entire contents of n-link-core/assets/tailwind.css with:
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Slate theme tokens. Dark is the default (and the fallback when the OS
preference is unknown); light applies when the OS asks for it. The
[data-theme] blocks let a future manual toggle force a theme. */
:root,
:root[data-theme='dark'] {
--surface: #0f1117;
--raised: #161a25;
--edge: #1d2130;
--fg: #e6e8ee;
--muted: #8b91a3;
--accent: #2d4bd8;
--accent-fg: #ffffff;
--selected: #1a2547;
--ok: #2dd4bf;
--warn: #f5b942;
--danger: #ef4444;
--success: #22c55e;
}
@media (prefers-color-scheme: light) {
:root:not([data-theme='dark']) {
--surface: #f7f8fa;
--raised: #ffffff;
--edge: #e6e9ef;
--fg: #1c2330;
--muted: #6b7280;
--accent: #2d4bd8;
--accent-fg: #ffffff;
--selected: #e4ebff;
--ok: #0d9488;
--warn: #d97706;
--danger: #dc2626;
--success: #16a34a;
}
}
:root[data-theme='light'] {
--surface: #f7f8fa;
--raised: #ffffff;
--edge: #e6e9ef;
--fg: #1c2330;
--muted: #6b7280;
--accent: #2d4bd8;
--accent-fg: #ffffff;
--selected: #e4ebff;
--ok: #0d9488;
--warn: #d97706;
--danger: #dc2626;
--success: #16a34a;
}
@keyframes lds-dual-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
(The dead .el-popover rule is dropped — Element-Plus is being removed. The light values are intentionally duplicated across the media query and the [data-theme='light'] override; CSS variables require concrete values in each block.)
- Step 3: Map tokens into Tailwind and add Inter
In n-link-core/tailwind.config.js: (a) prepend 'Inter' to theme.fontFamily.sans; (b) replace the entire colors: { ui: {...} } block under theme.extend with the semantic token colors below (the ui.* palette is unused dead scaffolding — remove it):
colors: {
surface: 'var(--surface)',
raised: 'var(--raised)',
edge: 'var(--edge)',
fg: 'var(--fg)',
muted: 'var(--muted)',
accent: {
DEFAULT: 'var(--accent)',
fg: 'var(--accent-fg)',
},
selected: 'var(--selected)',
ok: 'var(--ok)',
warn: 'var(--warn)',
danger: 'var(--danger)',
success: 'var(--success)',
},
The fontFamily.sans array's first element becomes 'Inter', followed by the existing 'Cantarell', 'Roboto', ... entries unchanged.
- Step 4: Import the Inter font
In desktop/src/main.ts, add these imports directly below the existing import 'n-link-core/assets/tailwind.css'; line:
import '@fontsource/inter/400.css';
import '@fontsource/inter/500.css';
import '@fontsource/inter/600.css';
import '@fontsource/inter/700.css';
- Step 5: Apply base tokens to the app shell
In desktop/src/App.vue, replace the <style lang="scss"> block with:
@import 'n-link-core/assets/tailwind.css';
#app {
user-select: none;
font-family: 'Inter', theme('fontFamily.sans');
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--surface);
color: var(--fg);
}
(Removes the hardcoded color: #2c3e50 and the Avenir, Helvetica, Arial stack.)
- Step 6: Verify the app builds and tokens resolve
Run: cd desktop && npm run build
Expected: build succeeds (no vue-tsc or Tailwind errors).
Then run: cd desktop && npm run dev, open http://localhost:1420. Expected: the window background is dark slate (#0f1117) and text is light; switching your OS to light mode and reloading shows the light palette. Stop the dev server when confirmed.
- Step 7: Commit
git add n-link-core/assets/tailwind.css n-link-core/tailwind.config.js \
desktop/package.json desktop/src/main.ts desktop/src/App.vue package-lock.json
git commit -m "feat(ui): add Slate semantic theme tokens and Inter font"
Task 3: ProgressBar component
Files:
- Create:
n-link-core/components/ProgressBar.vue - Test:
n-link-core/components/ProgressBar.spec.ts
Interfaces:
-
Produces:
ProgressBar.vuewith props{ fraction: number; color?: 'accent' | 'ok' | 'warn' }(default'accent').fractionis 0–1 filled and is clamped to that range. The filled element carriesdata-testid="fill"and an inlinewidthpercentage, and abg-{color}class. -
Step 1: Write the failing test
Create n-link-core/components/ProgressBar.spec.ts:
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import ProgressBar from './ProgressBar.vue';
const fill = (w: ReturnType<typeof mount>) => w.get('[data-testid="fill"]');
describe('ProgressBar', () => {
it('renders fraction as a width percentage', () => {
const wrapper = mount(ProgressBar, { props: { fraction: 0.5 } });
expect(fill(wrapper).attributes('style')).toContain('width: 50%');
});
it('clamps fractions below 0 and above 1', () => {
expect(fill(mount(ProgressBar, { props: { fraction: -0.3 } }))
.attributes('style')).toContain('width: 0%');
expect(fill(mount(ProgressBar, { props: { fraction: 1.8 } }))
.attributes('style')).toContain('width: 100%');
});
it('applies the accent color class by default', () => {
const wrapper = mount(ProgressBar, { props: { fraction: 0.5 } });
expect(fill(wrapper).classes()).toContain('bg-accent');
});
it('applies a chosen color class', () => {
const wrapper = mount(ProgressBar, { props: { fraction: 0.5, color: 'ok' } });
expect(fill(wrapper).classes()).toContain('bg-ok');
});
});
- Step 2: Run the test to verify it fails
Run: cd desktop && npm test -- ProgressBar
Expected: FAIL — cannot resolve ./ProgressBar.vue.
- Step 3: Write the component
Create n-link-core/components/ProgressBar.vue:
<template>
<div class="w-full bg-edge rounded-full overflow-hidden">
<div data-testid="fill" class="py-1 rounded-full" :class="`bg-${color}`"
:style="{ width: `${percent}%` }"/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
const props = withDefaults(defineProps<{
fraction: number;
color?: 'accent' | 'ok' | 'warn';
}>(), {
color: 'accent',
});
const percent = computed(() => Math.min(100, Math.max(0, props.fraction * 100)));
</script>
- Step 4: Run the test to verify it passes
Run: cd desktop && npm test -- ProgressBar
Expected: PASS — 4 passed.
- Step 5: Commit
git add n-link-core/components/ProgressBar.vue n-link-core/components/ProgressBar.spec.ts
git commit -m "feat(ui): add ProgressBar component"
Task 4: AppButton component
Files:
- Create:
n-link-core/components/AppButton.vue - Test:
n-link-core/components/AppButton.spec.ts
Interfaces:
-
Produces:
AppButton.vuewith props{ variant?: 'primary' | 'secondary' | 'danger' | 'success'; size?: 'md' | 'sm'; disabled?: boolean }(defaults'primary','md',false). Renders a<button>wrapping the default slot, forwards nativeclick, and reflectsdisabled. -
Step 1: Write the failing test
Create n-link-core/components/AppButton.spec.ts:
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import AppButton from './AppButton.vue';
describe('AppButton', () => {
it('renders slot content', () => {
const wrapper = mount(AppButton, { slots: { default: 'Download' } });
expect(wrapper.text()).toBe('Download');
});
it('applies the primary accent style by default', () => {
const wrapper = mount(AppButton, { slots: { default: 'Go' } });
expect(wrapper.get('button').classes()).toContain('bg-accent');
});
it('applies the danger variant', () => {
const wrapper = mount(AppButton, {
props: { variant: 'danger' }, slots: { default: 'Delete' },
});
expect(wrapper.get('button').classes()).toContain('bg-danger');
});
it('emits click when enabled', async () => {
const wrapper = mount(AppButton, { slots: { default: 'Go' } });
await wrapper.get('button').trigger('click');
expect(wrapper.emitted('click')).toHaveLength(1);
});
it('reflects the disabled prop', () => {
const wrapper = mount(AppButton, {
props: { disabled: true }, slots: { default: 'Go' },
});
expect(wrapper.get('button').attributes('disabled')).toBeDefined();
});
});
- Step 2: Run the test to verify it fails
Run: cd desktop && npm test -- AppButton
Expected: FAIL — cannot resolve ./AppButton.vue.
- Step 3: Write the component
Create n-link-core/components/AppButton.vue:
<template>
<button class="rounded font-bold focus:outline-none focus-visible:ring-2
focus-visible:ring-accent transition-colors disabled:opacity-75
disabled:cursor-not-allowed"
:class="[variantClass, sizeClass]" :disabled="disabled">
<slot/>
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue';
const props = withDefaults(defineProps<{
variant?: 'primary' | 'secondary' | 'danger' | 'success';
size?: 'md' | 'sm';
disabled?: boolean;
}>(), {
variant: 'primary',
size: 'md',
disabled: false,
});
const variantClass = computed(() => ({
primary: 'bg-accent text-accent-fg',
secondary: 'bg-raised text-fg border border-edge',
danger: 'bg-danger text-white',
success: 'bg-success text-white',
}[props.variant]));
const sizeClass = computed(() => (props.size === 'sm' ? 'px-3 py-2 text-sm' : 'px-6 py-2.5'));
</script>
- Step 4: Run the test to verify it passes
Run: cd desktop && npm test -- AppButton
Expected: PASS — 5 passed.
- Step 5: Commit
git add n-link-core/components/AppButton.vue n-link-core/components/AppButton.spec.ts
git commit -m "feat(ui): add AppButton component"
Task 5: Popover component
Files:
- Create:
n-link-core/components/Popover.vue - Test:
n-link-core/components/Popover.spec.ts
Interfaces:
-
Produces:
Popover.vuewith avisiblev-model (defineModel<boolean>('visible')), props{ width?: number | string; placement?: 'bottom-start' | 'bottom-end' | 'top-start' }(default'bottom-start'), a#triggerslot (bound{ visible }, click toggles), and a default slot for the panel. The panel renders only when visible, carriesdata-testid="panel", and closes on outsidemousedownorEscape. Consumers can also setvisibledirectly (used by FileData's forms). -
Step 1: Write the failing test
Create n-link-core/components/Popover.spec.ts:
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import Popover from './Popover.vue';
function make() {
return mount(Popover, {
attachTo: document.body,
slots: {
trigger: '<button class="trg">open</button>',
default: '<div class="content">panel body</div>',
},
});
}
describe('Popover', () => {
it('hides the panel initially', () => {
expect(make().find('[data-testid="panel"]').exists()).toBe(false);
});
it('opens when the trigger is clicked', async () => {
const wrapper = make();
await wrapper.get('.trg').trigger('click');
expect(wrapper.find('[data-testid="panel"]').exists()).toBe(true);
expect(wrapper.emitted('update:visible')?.at(-1)).toEqual([true]);
});
it('closes on Escape', async () => {
const wrapper = make();
await wrapper.get('.trg').trigger('click');
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="panel"]').exists()).toBe(false);
});
it('closes on an outside mousedown', async () => {
const wrapper = make();
await wrapper.get('.trg').trigger('click');
document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="panel"]').exists()).toBe(false);
});
it('applies a width style to the panel', async () => {
const wrapper = mount(Popover, {
attachTo: document.body,
props: { width: 200 },
slots: { trigger: '<button class="trg">o</button>', default: '<div>b</div>' },
});
await wrapper.get('.trg').trigger('click');
expect(wrapper.get('[data-testid="panel"]').attributes('style')).toContain('width: 200px');
});
});
- Step 2: Run the test to verify it fails
Run: cd desktop && npm test -- Popover
Expected: FAIL — cannot resolve ./Popover.vue.
- Step 3: Write the component
Create n-link-core/components/Popover.vue:
<template>
<div ref="root" class="relative inline-block">
<div @click="toggle">
<slot name="trigger" :visible="visible"/>
</div>
<div v-if="visible" data-testid="panel"
class="absolute z-50 mt-1 bg-raised border border-edge rounded-lg shadow-lg
p-3 select-none" :class="placementClass" :style="panelStyle">
<slot/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
const props = withDefaults(defineProps<{
width?: number | string;
placement?: 'bottom-start' | 'bottom-end' | 'top-start';
}>(), {
placement: 'bottom-start',
});
const visible = defineModel<boolean>('visible', { default: false });
const root = ref<HTMLElement | null>(null);
function toggle() {
visible.value = !visible.value;
}
function onDocMousedown(e: MouseEvent) {
if (root.value && !root.value.contains(e.target as Node)) visible.value = false;
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') visible.value = false;
}
watch(visible, (open) => {
if (open) {
document.addEventListener('mousedown', onDocMousedown);
document.addEventListener('keydown', onKeydown);
} else {
document.removeEventListener('mousedown', onDocMousedown);
document.removeEventListener('keydown', onKeydown);
}
});
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onDocMousedown);
document.removeEventListener('keydown', onKeydown);
});
const placementClass = computed(() => ({
'bottom-start': 'top-full left-0',
'bottom-end': 'top-full right-0',
'top-start': 'bottom-full left-0 mb-1',
}[props.placement]));
const panelStyle = computed(() =>
props.width === undefined
? undefined
: { width: typeof props.width === 'number' ? `${props.width}px` : props.width });
</script>
- Step 4: Run the test to verify it passes
Run: cd desktop && npm test -- Popover
Expected: PASS — 5 passed.
- Step 5: Commit
git add n-link-core/components/Popover.vue n-link-core/components/Popover.spec.ts
git commit -m "feat(ui): add Popover component to replace element-plus"
Task 6: Icon component
Files:
- Create:
n-link-core/components/Icon.vue - Test:
n-link-core/components/Icon.spec.ts - Modify:
n-link-core/package.json
Interfaces:
-
Produces:
Icon.vuewith props{ name: string; size?: number }(default20). Renders the named feather icon inline (viafeather.icons[name].toSvg), so the SVG'sstroke="currentColor"inherits the surrounding text color. ThrowsError("unknown feather icon: <name>")for an unknown name. -
Step 1: Declare feather-icons as a dependency of n-link-core
In n-link-core/package.json, add a dependencies block (the file currently has only peerDependencies and devDependencies):
"dependencies": {
"feather-icons": "^4.29.2"
},
Run: npm install from repo root.
- Step 2: Write the failing test
Create n-link-core/components/Icon.spec.ts:
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import Icon from './Icon.vue';
describe('Icon', () => {
it('renders an inline svg that inherits currentColor', () => {
const wrapper = mount(Icon, { props: { name: 'refresh-cw' } });
expect(wrapper.html()).toContain('<svg');
expect(wrapper.html()).toContain('currentColor');
});
it('applies the size to width and height', () => {
const wrapper = mount(Icon, { props: { name: 'chevron-right', size: 32 } });
expect(wrapper.html()).toContain('width="32"');
expect(wrapper.html()).toContain('height="32"');
});
it('throws for an unknown icon name', () => {
expect(() => mount(Icon, { props: { name: 'not-a-real-icon' } }))
.toThrow(/unknown feather icon/);
});
});
- Step 3: Run the test to verify it fails
Run: cd desktop && npm test -- Icon
Expected: FAIL — cannot resolve ./Icon.vue.
- Step 4: Write the component
Create n-link-core/components/Icon.vue:
<template>
<span class="inline-flex items-center" v-html="svg"/>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import feather from 'feather-icons';
const props = withDefaults(defineProps<{
name: string;
size?: number;
}>(), {
size: 20,
});
const svg = computed(() => {
const icon = feather.icons[props.name as keyof typeof feather.icons];
if (!icon) throw new Error(`unknown feather icon: ${props.name}`);
return icon.toSvg({ width: props.size, height: props.size });
});
</script>
- Step 5: Run the test to verify it passes
Run: cd desktop && npm test -- Icon
Expected: PASS — 3 passed.
- Step 6: Commit
git add n-link-core/components/Icon.vue n-link-core/components/Icon.spec.ts \
n-link-core/package.json package-lock.json
git commit -m "feat(ui): add inline Icon component for themeable feather icons"
Task 7: Restyle DeviceSelect
Files:
- Modify:
n-link-core/components/DeviceSelect.vue
Interfaces:
-
Consumes:
Popover.vue(Task 5),Icon.vue(Task 6), theme tokens (Task 2). -
Step 1: Rewrite the component with Popover + Icon + tokens
Replace the entire contents of n-link-core/components/DeviceSelect.vue with:
<template>
<div class="header border-b border-edge px-2 py-2 flex w-full bg-raised">
<button @click="$devices.enumerate()" class="flex-shrink-0 mr-2 focus:outline-none text-fg"
:class="$devices.enumerating && 'cursor-not-allowed opacity-25'" :disabled="$devices.enumerating">
<icon name="refresh-cw" :size="20"/>
<div v-if="scanHint && Object.keys($devices.devices).length === 0" class="p-4 refresh-popup">
Click to connect a device
</div>
</button>
<popover v-model:visible="active" :width="239" class="w-full">
<template #trigger>
<div class="relative w-full focus:outline-none">
<div class="block w-full bg-surface border border-edge hover:border-muted px-4 py-3/2 pr-8
rounded leading-tight focus:outline-none h-8 truncate text-fg">
<span v-if="selectedCalculator">
<small class="tabular-nums">{{ selectedCalculator }}</small>
<span v-if="calc && calc.info"> {{ calc.info.name }}</span>
<span v-else> {{ calc!.name }}</span>
</span>
<span v-else class="text-muted text-sm">
Select a device...
</span>
</div>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-muted">
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z"/>
</svg>
</div>
</div>
</template>
<ul class="-m-3">
<li v-for="(device, id) in $devices.devices" :key="id"
class="p-2 hover:bg-accent hover:text-accent-fg w-full cursor-pointer text-fg" @click="select(id)">
<small class="tabular-nums">{{ id }}</small>
<span v-if="device.info"> {{ device.info.name }}</span>
<span v-else> {{ device.name }}</span>
</li>
<div v-if="!anyCalcs" class="p-2 w-full text-muted">
No calculators found
</div>
</ul>
</popover>
</div>
</template>
<script setup lang="ts">
import {computed, inject, ref} from 'vue';
import {DEVICES_KEY, type GenericDevices} from './devices';
import Popover from './Popover.vue';
import Icon from './Icon.vue';
withDefaults(defineProps<{
scanHint?: boolean;
}>(), {
scanHint: false,
});
const selectedCalculator = defineModel<string | null>('selected');
const devices = inject(DEVICES_KEY) as GenericDevices;
const active = ref(false);
function select(dev: string) {
selectedCalculator.value = dev;
active.value = false;
}
const calc = computed(() => {
const id = selectedCalculator.value;
return id ? devices.devices[id] : undefined;
});
const anyCalcs = computed(() => !!Object.keys(devices.devices).length);
</script>
<style scoped lang="scss">
.header {
height: 50px;
}
.refresh-popup {
$offset: 5px;
$size: 9px;
margin-left: -4.5px;
margin-top: 10px;
@apply absolute bg-accent text-accent-fg;
&:before {
content: "";
position: absolute;
left: $offset;
top: $size * -2;
border-top: $size solid transparent;
border-right: $size solid transparent;
border-bottom: $size solid var(--accent);
border-left: $size solid transparent;
}
}
</style>
(The el-popover, its element-plus imports, the feather-icons/dist/icons/refresh-cw.svg import, and the second <style> block for .dev-select-pop are all gone. The <ul class="-m-3"> negative margin lets the list fill the Popover's p-3 padding.)
- Step 2: Verify build and no element-plus remains
Run: cd desktop && npm run build
Expected: build succeeds.
Run: grep -n "element-plus" n-link-core/components/DeviceSelect.vue
Expected: no output.
- Step 3: Visual check
Run: cd desktop && npm run dev, open http://localhost:1420. Expected: the top bar is a raised slate surface, the refresh icon is visible in the theme color, and clicking the device selector opens a themed dropdown. Stop the dev server.
- Step 4: Commit
git add n-link-core/components/DeviceSelect.vue
git commit -m "feat(ui): restyle DeviceSelect with Popover, Icon, and Slate tokens"
Task 8: Restyle CalcInfo
Files:
- Modify:
n-link-core/components/CalcInfo.vue
Interfaces:
-
Consumes:
AppButton.vue(Task 4),ProgressBar.vue(Task 3), theme tokens. -
Step 1: Replace the template markup
In n-link-core/components/CalcInfo.vue, replace the storage/RAM bar <div>s and the two <button>s in the <template> with token-based markup. Replace the block from <div :title="...free storage..."> through the closing of the RAM block with:
<div :title="`${formatSize(info.free_storage)} free`">
<small class="block select-text text-muted">
Storage: {{ formatSize(info.total_storage - info.free_storage) }} / {{ formatSize(info.total_storage) }} used
</small>
<progress-bar class="mb-2" :fraction="1 - info.free_storage / info.total_storage" color="accent"/>
</div>
<div :title="`${formatSize(info.free_ram)} free`">
<small class="block select-text text-muted">
RAM: {{ formatSize(info.total_ram - info.free_ram) }} / {{ formatSize(info.total_ram) }} used
</small>
<progress-bar class="mb-2" :fraction="1 - info.free_ram / info.total_ram" color="ok"/>
</div>
Replace the two buttons (Refresh and Upload OS) with:
<app-button class="mt-4" :disabled="refreshing" @click="refresh">
<div class="flex">
<div v-if="refreshing" class="lds-dual-ring"/>
Refresh
</div>
</app-button>
<app-button class="mt-4" variant="secondary"
@click="nativeUpload ? upload?.click() : $devices.uploadOs(dev, osExtension)">
Upload OS
</app-button>
- Step 2: Update the script imports and drop the old button SCSS
In the <script setup> block of CalcInfo.vue, add below the existing imports:
import AppButton from './AppButton.vue';
import ProgressBar from './ProgressBar.vue';
In the <style scoped lang="scss"> block, delete the .button and .gray-button rules entirely, keeping only the .lds-dual-ring rules, and change the spinner border color from white to var(--accent-fg):
.lds-dual-ring:after {
content: " ";
display: block;
width: 64px;
height: 64px;
margin: 8px;
border-radius: 50%;
border: 6px solid var(--accent-fg);
border-color: var(--accent-fg) transparent var(--accent-fg) transparent;
animation: lds-dual-ring 1.2s linear infinite;
}
(Keep the .lds-dual-ring sizing rule above it unchanged.)
- Step 3: Verify build
Run: cd desktop && npm run build
Expected: build succeeds.
Run: grep -nE "bg-blue|bg-gray|bg-teal" n-link-core/components/CalcInfo.vue
Expected: no output.
- Step 4: Commit
git add n-link-core/components/CalcInfo.vue
git commit -m "feat(ui): restyle CalcInfo with AppButton and ProgressBar"
Task 9: Restyle DeviceQueue
Files:
- Modify:
n-link-core/components/DeviceQueue.vue
Interfaces:
-
Consumes:
Popover.vue,ProgressBar.vue,Icon.vue, theme tokens. -
Step 1: Rewrite the template to use Popover, ProgressBar, and Icon
In n-link-core/components/DeviceQueue.vue, replace the <template> block with:
<template>
<div>
<popover :width="300" placement="bottom-end">
<template #trigger>
<svg viewBox="-1 -1 2 2" class="circle focus:outline-none">
<circle r="1" class="bg"/>
<path class="fg" :d="pathData"/>
</svg>
</template>
<div v-if="!queue.length" class="text-muted">
Nothing to do
</div>
<div v-for="(item, i) in queue" :key="item.id" class="flex items-center text-fg">
<div class="min-w-0 flex-grow">
<p class="truncate">{{ item.desc }}</p>
<div v-if="i === 0 && device.progress">
<small class="block tabular-nums text-muted">
{{ (100 - device.progress.remaining / device.progress.total * 100).toFixed(1) }}%
</small>
<progress-bar class="mb-2"
:fraction="1 - device.progress.remaining / device.progress.total" color="ok"/>
</div>
</div>
<div class="ml-2 flex-shrink-0">
<button class="focus:outline-none text-fg" :disabled="i===0" :class="i===0 && 'cursor-not-allowed'"
@click="device.queue?.splice(i, 1)">
<icon name="x-circle" :size="20" :class="i===0 && 'opacity-25'"/>
</button>
</div>
</div>
</popover>
</div>
</template>
- Step 2: Update the script imports
In the <script setup> block, remove the two element-plus import lines and the xCircle feather import, and add:
import Popover from './Popover.vue';
import ProgressBar from './ProgressBar.vue';
import Icon from './Icon.vue';
(The computed, Device type, getCoordinatesForPercent, trimPath, pathData, and queue logic all stay unchanged.)
- Step 3: Recolor the progress ring
In the <style scoped lang="scss"> block, change the ring fills to tokens:
.circle {
width: 32px;
height: 32px;
transform: rotate(-90deg);
.bg {
fill: var(--edge);
}
.fg {
fill: var(--accent);
}
}
- Step 4: Verify build and no element-plus remains
Run: cd desktop && npm run build
Expected: build succeeds.
Run: grep -n "element-plus" n-link-core/components/DeviceQueue.vue
Expected: no output.
- Step 5: Commit
git add n-link-core/components/DeviceQueue.vue
git commit -m "feat(ui): restyle DeviceQueue with Popover, ProgressBar, and Icon"
Task 10: Restyle FileData
Files:
- Modify:
n-link-core/components/FileData.vue
Interfaces:
-
Consumes:
Popover.vue,AppButton.vue, theme tokens. -
Step 1: Rewrite the template with Popover + AppButton
Replace the <template> block of n-link-core/components/FileData.vue with:
<template>
<div class="flex flex-col h-full">
<div v-if="files.length === 1">
<div class="w-full flex flex-col items-center">
<file-icon :path="files[0].path" :dir="files[0].isDir" :width="96"/>
<p class="text-center w-full break-words text-fg">{{ formatPath(files[0]) }}</p>
<p v-if="!files[0].isDir" class="mt-2 text-sm text-muted">{{ formatSize(files[0].size) }}</p>
</div>
<app-button v-if="!files[0].isDir" class="mt-4 w-full" @click="download">
Download
</app-button>
<popover v-model:visible="renamePopup" :width="190" class="w-full block">
<template #trigger>
<app-button class="mt-4 w-full" variant="secondary" @click="newName = formatPath(files[0])">
Rename
</app-button>
</template>
<form @submit.prevent="rename">
<input v-model="newName"
class="border border-edge rounded w-full py-2 px-3 bg-surface text-fg leading-tight focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
type="text" placeholder="New name">
<app-button class="mt-4 w-full" variant="success" :disabled="!isValidName" type="submit">
Rename
</app-button>
</form>
</popover>
</div>
<div v-else-if="files.length && files.every(file => !file.isDir)">
<app-button class="mt-4" @click="download">
Download {{ files.length > 1 ? `${files.length} files` : '' }}
</app-button>
</div>
<div v-if="files.length">
<popover v-model:visible="deletePopup" :width="170" class="w-full block">
<template #trigger>
<app-button class="mt-4 w-full" variant="danger">
Delete {{ files.length > 1 ? `${files.length} files` : '' }}
</app-button>
</template>
<div class="text-fg">
Delete {{ files.length }} file{{ files.length === 1 ? '' : 's' }}?
<div class="flex w-full justify-between">
<app-button class="mt-4" size="sm" variant="secondary" @click="deletePopup = false">
Cancel
</app-button>
<app-button class="mt-4" size="sm" variant="danger" @click="deleteFiles">
Delete
</app-button>
</div>
</div>
</popover>
</div>
<div class="flex-grow"/>
<div class="pb-4">
<popover v-model:visible="createDirPopup" :width="170" class="w-full block" placement="top-start">
<template #trigger>
<app-button class="mt-4 w-full" variant="secondary" size="sm" @click="newName = ''">
Create directory
</app-button>
</template>
<form @submit.prevent="$devices.createDir(dev, `${path}/${newName}`)">
<input v-model="newName"
class="border border-edge rounded w-full py-2 px-3 bg-surface text-fg leading-tight focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
type="text" placeholder="New directory">
<app-button class="mt-4 w-full" variant="success" :disabled="!newName.length" type="submit">
Create
</app-button>
</form>
</popover>
<app-button class="mt-4 w-full" variant="secondary"
@click="nativeUpload ? upload?.click() : $devices.promptUploadFiles(dev, path)">
Upload files
</app-button>
<input v-if="nativeUpload" ref="upload" type="file" class="hidden" multiple accept=".tns" @change="uploadNative" />
</div>
</div>
</template>
(Note: the rename form's submit button previously showed a disabled style via a .disabled class; AppButton's disabled prop now handles that. The createDir popover uses placement="top-start" because it sits at the bottom of the rail.)
- Step 2: Update the script imports and drop the button SCSS
In the <script setup> block, remove the two element-plus import lines and add:
import AppButton from './AppButton.vue';
import Popover from './Popover.vue';
Delete the entire <style scoped lang="scss"> block (all .button variants are now provided by AppButton).
- Step 3: Verify build and no element-plus remains
Run: cd desktop && npm run build
Expected: build succeeds.
Run: grep -n "element-plus" n-link-core/components/FileData.vue
Expected: no output.
- Step 4: Commit
git add n-link-core/components/FileData.vue
git commit -m "feat(ui): restyle FileData with Popover and AppButton"
Task 11: Restyle FileView, FileBrowser, and Home
Files:
- Modify:
n-link-core/components/FileView.vue - Modify:
n-link-core/components/FileBrowser.vue - Modify:
desktop/src/views/Home.vue
Interfaces:
-
Consumes:
Icon.vue, theme tokens. -
Step 1: Token-migrate FileView selection
In n-link-core/components/FileView.vue, replace the <style scoped lang="scss"> block with:
.selected {
@apply rounded bg-selected;
p {
@apply text-fg;
}
}
(Removes the hardcoded #4d88e8 background and text-white.)
- Step 2: Token-migrate FileBrowser breadcrumbs and detail rail, use Icon
In n-link-core/components/FileBrowser.vue: change the header <div> border to border-b border-edge; change the breadcrumb pill span classes from bg-gray-200 to bg-raised text-fg; replace the chevron <img :src="chevronRight" .../> with <icon name="chevron-right" :size="16" class="inline text-muted"/>; change the detail rail wrapper border-l to border-l border-edge. In <script setup>, remove the chevronRight feather import and add import Icon from './Icon.vue';. In <style scoped>, change the active breadcrumb rule to:
.active span {
@apply bg-accent text-accent-fg;
}
Recolor the .lds-dual-ring:after border from theme('colors.gray.400') to var(--muted).
- Step 3: Token-migrate Home sidebar and spinner
In desktop/src/views/Home.vue: change the sidebar wrapper border-r to border-r border-edge; change the driver-message headings and the text-blue-600 links to text-accent; change the checkbox label text-gray-700 to text-muted and the checkbox text-blue-600 to text-accent. In <style scoped>, change the .lds-dual-ring:after $color from theme('colors.gray.400') to var(--muted).
- Step 4: Verify build and no stray palette classes
Run: cd desktop && npm run build
Expected: build succeeds.
Run: grep -nE "bg-gray-200|text-blue-600|#4d88e8|colors.gray.400" n-link-core/components/FileView.vue n-link-core/components/FileBrowser.vue desktop/src/views/Home.vue
Expected: no output.
- Step 5: Visual check
Run: cd desktop && npm run dev, open http://localhost:1420. Expected: breadcrumbs, file grid selection, detail rail, and sidebar all render in the Slate palette; the chevron icon is visible. Stop the dev server.
- Step 6: Commit
git add n-link-core/components/FileView.vue n-link-core/components/FileBrowser.vue desktop/src/views/Home.vue
git commit -m "feat(ui): restyle file browser, file view, and home sidebar with tokens"
Task 12: Remove Element-Plus and final verification
Files:
- Modify:
n-link-core/package.json - Modify:
desktop/package.json
Interfaces:
-
Consumes: all prior tasks (every
el-popoverusage must already be gone). -
Step 1: Confirm no element-plus usage remains anywhere
Run: grep -rn "element-plus\|el-popover\|ElPopover" --include=*.vue --include=*.ts n-link-core desktop | grep -v node_modules
Expected: no output. (If anything prints, fix that file before continuing — it means a prior task's migration was incomplete.)
- Step 2: Drop the dependency
Remove the "element-plus": "^2.14.3", line from desktop/package.json dependencies, and remove the "element-plus": "^2.14.3", line from n-link-core/package.json peerDependencies (leaving vue in that block). Run: npm install from repo root.
- Step 3: Full build and test sweep
Run: cd desktop && npm run build && npm test
Expected: build succeeds; all unit tests pass (ProgressBar 4, AppButton 5, Popover 5, Icon 3).
- Step 4: Full visual smoke test
Run: cd desktop && npm run dev, open http://localhost:1420. Walk through: dark theme by default; switch OS to light and reload → light theme; the device dropdown, transfer-queue ring/panel, and file rename/delete/create-dir popovers all open and are themed; icons visible in both themes. Stop the dev server.
- Step 5: Commit
git add desktop/package.json n-link-core/package.json package-lock.json
git commit -m "chore: drop element-plus dependency"
Self-Review Notes
- Spec coverage: Theming mechanic → Task 2; icon fix → Task 6 + integrations (7, 9, 11); remove Element-Plus + 3 primitives → Tasks 3–6, 12 (Popover replaces all three
el-popoversites in Tasks 7, 9, 10); per-component restyle → Tasks 7–11; Inter/global shell → Task 2; testing → Vitest primitives + per-task build/grep/visual checks.FileIcon.vueexplicitly excluded with rationale (multicolor glyphs). - Type consistency:
ProgressBarpropfraction(0–1) is used consistently in CalcInfo and DeviceQueue by passing1 - free/total.Popoverusesvisiblev-model and#triggerslot consistently across DeviceSelect, DeviceQueue, FileData.AppButtonvariant names (primary/secondary/danger/success) match every call site. Token utility names match the Global Constraints list everywhere. - No placeholders: every code step shows complete content.