diff --git a/.changeset/web-ui-primitives.md b/.changeset/web-ui-primitives.md
new file mode 100644
index 00000000..52279761
--- /dev/null
+++ b/.changeset/web-ui-primitives.md
@@ -0,0 +1,5 @@
+---
+"@pymodel/pythinker-code": patch
+---
+
+Add four shared UI primitives to the web app: `Popover`, `MenuRow`, `SwitchToggle` and `Chip`. `Popover` holds the anchored-menu positioning that each menu used to write for itself, including the flip above the trigger and the viewport clamp. `MenuRow` carries the standard list row, sized from `--ui-font-size` so the font-size setting still scales it. All four style themselves only from theme tokens, and a guard test fails on any colour literal.
diff --git a/apps/pythinker-web/src/components/ui/Chip.vue b/apps/pythinker-web/src/components/ui/Chip.vue
new file mode 100644
index 00000000..7c2a6e31
--- /dev/null
+++ b/apps/pythinker-web/src/components/ui/Chip.vue
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+ ×
+
+
+ {{ label }}
+
+
+
+
+
diff --git a/apps/pythinker-web/src/components/ui/MenuRow.vue b/apps/pythinker-web/src/components/ui/MenuRow.vue
new file mode 100644
index 00000000..573a6cad
--- /dev/null
+++ b/apps/pythinker-web/src/components/ui/MenuRow.vue
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
diff --git a/apps/pythinker-web/src/components/ui/Popover.vue b/apps/pythinker-web/src/components/ui/Popover.vue
new file mode 100644
index 00000000..20d2893c
--- /dev/null
+++ b/apps/pythinker-web/src/components/ui/Popover.vue
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/pythinker-web/src/components/ui/SwitchToggle.vue b/apps/pythinker-web/src/components/ui/SwitchToggle.vue
new file mode 100644
index 00000000..ed94338b
--- /dev/null
+++ b/apps/pythinker-web/src/components/ui/SwitchToggle.vue
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/pythinker-web/test/ui-primitives.test.ts b/apps/pythinker-web/test/ui-primitives.test.ts
new file mode 100644
index 00000000..60d348d1
--- /dev/null
+++ b/apps/pythinker-web/test/ui-primitives.test.ts
@@ -0,0 +1,223 @@
+import { readdirSync, readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { mount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import { afterEach, describe, expect, it } from 'vitest';
+
+import Chip from '../src/components/ui/Chip.vue';
+import MenuRow from '../src/components/ui/MenuRow.vue';
+import Popover from '../src/components/ui/Popover.vue';
+import SwitchToggle from '../src/components/ui/SwitchToggle.vue';
+
+// Resolved from this file, not the cwd: the pre-push hook runs the suite from
+// the repository root.
+const uiDir = join(import.meta.dirname, '../src/components/ui');
+
+function sourceFiles(directory: string): string[] {
+ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
+ const path = join(directory, entry.name);
+ return entry.isDirectory() ? sourceFiles(path) : [path];
+ });
+}
+
+async function settle(): Promise {
+ await nextTick();
+ await nextTick();
+}
+
+afterEach(() => {
+ document.body.replaceChildren();
+});
+
+describe('MenuRow', () => {
+ it('renders its label and count when provided', () => {
+ const wrapper = mount(MenuRow, {
+ props: { count: 3 },
+ slots: { label: 'Models' },
+ });
+
+ expect(wrapper.text()).toContain('Models');
+ expect(wrapper.find('.count').text()).toBe('3');
+ });
+
+ it('omits the count element when count is not provided', () => {
+ const wrapper = mount(MenuRow, { slots: { label: 'Models' } });
+
+ expect(wrapper.find('.count').exists()).toBe(false);
+ });
+
+ it('applies the disabled state', () => {
+ const wrapper = mount(MenuRow, {
+ props: { disabled: true },
+ slots: { label: 'Models' },
+ });
+
+ expect(wrapper.find('button').attributes('disabled')).toBeDefined();
+ expect(wrapper.find('button').classes()).toContain('disabled');
+ });
+});
+
+describe('SwitchToggle', () => {
+ it('reflects aria-checked and emits on click', async () => {
+ const wrapper = mount(SwitchToggle, { props: { modelValue: false } });
+
+ expect(wrapper.attributes('aria-checked')).toBe('false');
+ await wrapper.trigger('click');
+ expect(wrapper.emitted('update:modelValue')).toEqual([[true]]);
+
+ await wrapper.setProps({ modelValue: true });
+ expect(wrapper.attributes('aria-checked')).toBe('true');
+ });
+
+ it('emits on keyboard activation', async () => {
+ const wrapper = mount(SwitchToggle, { props: { modelValue: false } });
+
+ await wrapper.trigger('keydown', { key: 'Enter' });
+ await wrapper.trigger('keydown', { key: ' ' });
+
+ expect(wrapper.emitted('update:modelValue')).toEqual([[true], [true]]);
+ });
+});
+
+describe('Chip', () => {
+ it('renders neutral and active variants and emits on click', async () => {
+ const neutral = mount(Chip, {
+ props: { label: 'Neutral', variant: 'neutral' },
+ slots: { icon: ' ' },
+ });
+ const active = mount(Chip, {
+ props: { label: 'Active', variant: 'active' },
+ slots: { icon: ' ' },
+ });
+
+ expect(neutral.classes()).toContain('neutral');
+ expect(active.classes()).toContain('active');
+ expect(active.text()).toContain('Active');
+
+ await active.trigger('click');
+ expect(active.emitted('click')).toHaveLength(1);
+ });
+});
+
+describe('Popover', () => {
+ it('opens and closes with the open prop', async () => {
+ const anchor = document.createElement('button');
+ document.body.append(anchor);
+ const wrapper = mount(Popover, {
+ attachTo: document.body,
+ props: { anchor, open: false },
+ slots: { default: 'Menu' },
+ });
+
+ expect(document.body.querySelector('[role="menu"]')).toBeNull();
+ await wrapper.setProps({ open: true });
+ expect(document.body.querySelector('[role="menu"]')?.textContent).toBe('Menu');
+ await wrapper.setProps({ open: false });
+ expect(document.body.querySelector('[role="menu"]')).toBeNull();
+ });
+
+ it('flips above the anchor and clamps to the viewport', async () => {
+ Object.defineProperty(window, 'innerWidth', { configurable: true, value: 800 });
+ Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 });
+ const anchor = document.createElement('button');
+ anchor.getBoundingClientRect = () => ({
+ bottom: 720,
+ height: 20,
+ left: 700,
+ right: 760,
+ top: 700,
+ width: 60,
+ x: 700,
+ y: 700,
+ toJSON: () => ({}),
+ });
+ document.body.append(anchor);
+ const wrapper = mount(Popover, {
+ attachTo: document.body,
+ props: { anchor, open: true },
+ slots: { default: 'Menu' },
+ });
+ const panel = document.body.querySelector('[role="menu"]') as HTMLElement;
+ Object.defineProperty(panel, 'offsetWidth', { configurable: true, value: 100 });
+ Object.defineProperty(panel, 'offsetHeight', { configurable: true, value: 80 });
+
+ await settle();
+
+ expect(readFileSync(join(uiDir, 'Popover.vue'), 'utf8')).toMatch(/position:\s*fixed/u);
+ expect(panel.style.top).toBe('616px');
+ expect(panel.style.left).toBe('684px');
+ wrapper.unmount();
+ });
+
+ it('closes on Escape', async () => {
+ const anchor = document.createElement('button');
+ document.body.append(anchor);
+ const wrapper = mount(Popover, {
+ attachTo: document.body,
+ props: { anchor, open: true },
+ });
+
+ await settle();
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+
+ expect(wrapper.emitted('close')).toHaveLength(1);
+ });
+
+ it('closes on outside pointerdown but not inside the panel', async () => {
+ const anchor = document.createElement('button');
+ const outside = document.createElement('div');
+ document.body.append(anchor, outside);
+ const wrapper = mount(Popover, {
+ attachTo: document.body,
+ props: { anchor, open: true },
+ slots: { default: 'Menu' },
+ });
+
+ await settle();
+ const panel = document.body.querySelector('[role="menu"]') as HTMLElement;
+ panel.dispatchEvent(new Event('pointerdown', { bubbles: true }));
+ expect(wrapper.emitted('close')).toBeUndefined();
+
+ outside.dispatchEvent(new Event('pointerdown', { bubbles: true }));
+ expect(wrapper.emitted('close')).toHaveLength(1);
+ });
+
+ it('restores focus to the anchor only when the panel held focus', async () => {
+ const anchor = document.createElement('button');
+ document.body.append(anchor);
+ anchor.focus();
+ const wrapper = mount(Popover, {
+ attachTo: document.body,
+ props: { anchor, open: false },
+ });
+
+ await wrapper.setProps({ open: true });
+ await settle();
+ const panel = document.body.querySelector('[role="menu"]') as HTMLElement;
+ panel.focus();
+ await wrapper.setProps({ open: false });
+ expect(document.activeElement).toBe(anchor);
+
+ await wrapper.setProps({ open: true });
+ await settle();
+ const outside = document.createElement('input');
+ document.body.append(outside);
+ outside.focus();
+ await wrapper.setProps({ open: false });
+ expect(document.activeElement).toBe(outside);
+ });
+});
+
+describe('UI primitive theme guard', () => {
+ it('keeps every source file free of forbidden color and dark-mode literals', () => {
+ const files = sourceFiles(uiDir).toSorted();
+
+ expect(files.length).toBeGreaterThanOrEqual(4);
+ for (const file of files) {
+ const source = readFileSync(file, 'utf8');
+ expect(source, file).not.toMatch(/\bdark:/u);
+ expect(source, file).not.toMatch(/#[\da-f]{3,8}\b/iu);
+ expect(source, file).not.toMatch(/\brgba?\s*\(/iu);
+ }
+ });
+});