Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,11 @@ onMounted(() => {
</script>

<template>
<div class="relative h-screen w-screen overflow-hidden bg-gradient-to-br from-slate-950 via-indigo-950 to-slate-900 text-slate-100">
<div class="relative h-screen w-screen overflow-hidden bg-gradient-to-br from-slate-950 via-indigo-950 to-slate-900 text-slate-100 max-md:overflow-y-auto max-md:overscroll-contain max-md:pb-16">
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<div class="text-center opacity-25">
<h1 class="text-5xl font-bold tracking-tight">DOMinic</h1>
<p class="mt-3 text-slate-400">An AI agent is the primary app author.</p>
<h1 class="text-5xl font-bold tracking-tight max-md:text-4xl">DOMinic</h1>
<p class="mt-3 text-slate-400 max-md:px-6">An AI agent is the primary app author.</p>
</div>
</div>

Expand Down Expand Up @@ -187,7 +187,7 @@ onMounted(() => {
:on-uninstall-app="uninstallApp"
/>

<div class="absolute right-4 top-4 z-50 flex flex-col gap-2 rounded-lg border border-white/10 bg-slate-900/90 p-3 backdrop-blur">
<div class="absolute right-4 top-4 z-50 flex flex-col gap-2 rounded-lg border border-white/10 bg-slate-900/90 p-3 backdrop-blur max-md:right-3 max-md:top-3">
<span class="text-xs font-semibold uppercase tracking-wide text-slate-400">System</span>
<button
class="rounded border border-dashed border-white/20 px-3 py-1.5 text-left text-xs text-slate-400 hover:text-slate-100"
Expand All @@ -197,6 +197,6 @@ onMounted(() => {
</button>
</div>

<span class="absolute right-4 bottom-16 z-50 text-xs text-slate-400">{{ now ?? "" }}</span>
<span class="absolute right-4 bottom-16 z-50 text-xs text-slate-400 max-md:hidden">{{ now ?? "" }}</span>
</div>
</template>
100 changes: 51 additions & 49 deletions app/features/os/components/Taskbar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,59 +26,61 @@ function onTaskbarClick(id: string): void {
</script>

<template>
<footer class="absolute bottom-0 left-0 right-0 z-40 flex h-12 items-center gap-2 border-t border-white/10 bg-slate-950/90 px-3 backdrop-blur">
<span class="text-sm font-bold tracking-tight text-indigo-300">DOMinic</span>
<span class="mx-2 h-6 w-px bg-white/10" />
<div
v-for="app in props.apps"
:key="app.id"
class="flex h-8 items-stretch overflow-hidden rounded border border-white/10 bg-slate-800"
>
<button
class="flex min-w-0 items-center gap-1.5 px-3 text-xs text-slate-200 hover:bg-slate-700"
:aria-label="`Open ${app.title}`"
@click="props.onOpenApp(app.id)"
<footer class="absolute bottom-0 left-0 right-0 z-40 flex h-12 max-md:h-14 max-md:fixed items-center gap-2 max-md:gap-1 border-t border-white/10 bg-slate-950/90 px-3 max-md:px-2 backdrop-blur max-md:pb-[max(env(safe-area-inset-bottom),0.25rem)]">
<div class="flex min-w-0 flex-1 max-md:flex-none items-center gap-2 max-md:gap-1 overflow-x-auto">
<span class="text-sm font-bold tracking-tight text-indigo-300 max-md:hidden">DOMinic</span>
<span class="mx-2 h-6 w-px bg-white/10 max-md:hidden" />
<div
v-for="app in props.apps"
:key="app.id"
class="flex h-8 max-md:h-11 items-stretch overflow-hidden rounded border border-white/10 bg-slate-800 shrink-0"
>
<span aria-hidden="true">{{ app.icon }}</span>
<span class="max-w-28 truncate">{{ app.title }}</span>
<span
class="h-1.5 w-1.5 shrink-0 rounded-full"
:class="isRunning(app.id) ? 'bg-emerald-400' : 'bg-slate-600'"
:aria-label="isRunning(app.id) ? 'Running' : 'Not running'"
/>
</button>
<button
class="flex w-8 items-center justify-center border-l border-white/10 text-slate-400 hover:bg-slate-700 hover:text-slate-100"
:aria-label="`View source for ${app.title}`"
title="View source"
@click="props.onViewSource(app.id)"
>
<Code2 :size="14" aria-hidden="true" />
</button>
<button
class="flex min-w-0 items-center gap-1.5 px-3 max-md:px-4 text-xs max-md:text-sm text-slate-200 hover:bg-slate-700 max-md:py-[10px]"
:aria-label="`Open ${app.title}`"
@click="props.onOpenApp(app.id)"
>
<span aria-hidden="true">{{ app.icon }}</span>
<span class="max-w-28 max-md:max-w-20 truncate">{{ app.title }}</span>
<span
class="h-1.5 w-1.5 shrink-0 rounded-full"
:class="isRunning(app.id) ? 'bg-emerald-400' : 'bg-slate-600'"
:aria-label="isRunning(app.id) ? 'Running' : 'Not running'"
/>
</button>
<button
class="flex w-8 max-md:w-11 items-center justify-center border-l border-white/10 text-slate-400 hover:bg-slate-700 hover:text-slate-100"
:aria-label="`View source for ${app.title}`"
title="View source"
@click="props.onViewSource(app.id)"
>
<Code2 :size="14" aria-hidden="true" />
</button>
<button
class="flex w-8 max-md:w-11 items-center justify-center border-l border-white/10 text-slate-400 hover:bg-red-500/80 hover:text-white"
:aria-label="`Uninstall ${app.title}`"
title="Uninstall"
@click="props.onUninstallApp(app.id)"
>
<Trash2 :size="14" aria-hidden="true" />
</button>
</div>
<span v-if="props.apps.length" class="h-6 w-px bg-white/10 max-md:hidden" />
<button
class="flex w-8 items-center justify-center border-l border-white/10 text-slate-400 hover:bg-red-500/80 hover:text-white"
:aria-label="`Uninstall ${app.title}`"
title="Uninstall"
@click="props.onUninstallApp(app.id)"
v-for="win in store.windows"
:key="win.id"
class="max-w-48 truncate rounded border px-3 max-md:px-4 py-1 max-md:py-2.5 text-xs max-md:text-sm transition-colors shrink-0"
:class="
win.minimized
? 'border-white/10 bg-slate-900 text-slate-400 hover:text-slate-100'
: store.focusedId === win.id
? 'border-indigo-400/60 bg-indigo-500/20 text-indigo-100'
: 'border-white/10 bg-slate-800 text-slate-200 hover:bg-slate-700'
"
@click="onTaskbarClick(win.id)"
>
<Trash2 :size="14" aria-hidden="true" />
{{ win.title }}
</button>
</div>
<span v-if="props.apps.length" class="h-6 w-px bg-white/10" />
<button
v-for="win in store.windows"
:key="win.id"
class="max-w-48 truncate rounded border px-3 py-1 text-xs transition-colors"
:class="
win.minimized
? 'border-white/10 bg-slate-900 text-slate-400 hover:text-slate-100'
: store.focusedId === win.id
? 'border-indigo-400/60 bg-indigo-500/20 text-indigo-100'
: 'border-white/10 bg-slate-800 text-slate-200 hover:bg-slate-700'
"
@click="onTaskbarClick(win.id)"
>
{{ win.title }}
</button>
</footer>
</template>
26 changes: 23 additions & 3 deletions app/features/os/components/WindowFrame.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,31 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { computed, ref, watch } from "vue";
import { useMediaQuery } from "@vueuse/core";
import { useOsStore, type OsWindow } from "../stores/os";

const props = defineProps<{ win: OsWindow }>();

const store = useOsStore();

// Below the md breakpoint the shell degrades to stacked full-width cards
// (ADR 0004, NEXT.md 3.1): the stored geometry is ignored, so dragging is
// a no-op at phone widths.
const isPhone = useMediaQuery("(max-width: 767px)");

const dragHandle = ref<HTMLElement | null>(null);
const dragging = ref(false);
const el = ref<HTMLElement | null>(null);

// A phone card has no visible neighbors; focusing a window scrolls it into
// view the way raising it does on desktop.
watch(
() => store.focusedId === props.win.id,
(focused) => {
if (isPhone.value && focused) {
el.value?.scrollIntoView({ behavior: "smooth", block: "start" });
}
},
);

const style = computed(() => {
if (props.win.state === "maximized") {
Expand All @@ -34,6 +52,7 @@ function clamp(x: number, min: number, max: number): number {

function onPointerDown(event: PointerEvent): void {
if (props.win.state === "maximized") return;
if (isPhone.value) return;
store.focusWindow(props.win.id);
dragging.value = true;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
Expand All @@ -57,15 +76,16 @@ function onPointerUp(event: PointerEvent): void {

<template>
<section
ref="el"
v-show="!win.minimized"
class="absolute flex flex-col overflow-hidden rounded-lg border border-white/10 bg-slate-900/95 shadow-2xl backdrop-blur"
class="absolute flex flex-col overflow-hidden rounded-lg border border-white/10 bg-slate-900/95 shadow-2xl backdrop-blur max-md:!static max-md:!mx-2 max-md:!mb-3 max-md:!mt-4 first:max-md:!mt-0 max-md:!h-[70vh] max-md:!w-auto"
:class="store.focusedId === win.id ? 'ring-2 ring-indigo-400' : ''"
:style="style"
@pointerdown="store.focusWindow(win.id)"
>
<header
ref="dragHandle"
class="flex h-9 shrink-0 cursor-grab items-center justify-between border-b border-white/10 bg-slate-800/80 px-3 select-none active:cursor-grabbing"
class="flex h-9 shrink-0 cursor-grab items-center justify-between border-b border-white/10 bg-slate-800/80 px-3 select-none active:cursor-grabbing max-md:cursor-default"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
Expand Down
119 changes: 119 additions & 0 deletions tests/e2e/mobile-shell.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { expect, test } from '@playwright/test'
import {
appWindow,
dominicHooks,
keepRequestsLocal,
openDesktop,
waitForDesktop,
} from './support/desktop'

// NEXT.md step 3.1: below 768px the shell degrades to stacked full-width
// cards, dragging is disabled, and the taskbar becomes a touch-sized
// bottom sheet. Every assertion here runs at a phone viewport.

test.use({ viewport: { width: 390, height: 844 } })

test.beforeEach(async ({ page, baseURL }) => {
await keepRequestsLocal(page, baseURL)
})

test('phone viewport: cards stack, drag is off, the taskbar sheet works', async ({ page }) => {
const dominic = dominicHooks(page)
await openDesktop(page)

await test.step('the installed app renders as a stacked card', async () => {
expect(await dominic.installFixture('pomodoro-timer')).toBe(true)
const pomodoro = appWindow(page, 'Pomodoro Timer')
await expect(pomodoro.getByText('25:00')).toBeVisible()

const card = await pomodoro.boundingBox()
expect(card).toBeTruthy()
expect(card!.width).toBeGreaterThan(340) // full width minus insets
expect(card!.width).toBeLessThanOrEqual(390)
})

await test.step('dragging the title bar does not move the card', async () => {
const pomodoro = appWindow(page, 'Pomodoro Timer')
const before = await pomodoro.boundingBox()

const handle = pomodoro.locator('header')
const box = await handle.boundingBox()
expect(box).toBeTruthy()
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2)
await page.mouse.down()
await page.mouse.move(box!.x + 120, box!.y + 80, { steps: 4 })
await page.mouse.up()

const after = await pomodoro.boundingBox()
expect(after!.x).toBeCloseTo(before!.x, 1)
expect(after!.y).toBeCloseTo(before!.y, 1)
})

await test.step('the agent chat stacks as its own card (insertion order)', async () => {
const chat = appWindow(page, 'Agent Chat')
await expect(
chat.getByPlaceholder("Ask DOMinic to build an app"),
).toBeVisible()
const chatBox = await chat.boundingBox()
const pomodoroBox = await appWindow(page, 'Pomodoro Timer').boundingBox()
expect(chatBox).toBeTruthy()
expect(pomodoroBox).toBeTruthy()
// Chat mounted first, so it is the top card; cards never overlap.
expect(chatBox!.y + chatBox!.height).toBeLessThanOrEqual(pomodoroBox!.y)
})

await test.step('the sheet stays pinned to the viewport bottom while cards scroll', async () => {
await page.locator('footer').evaluate((f) => {
const root = f.closest('.h-screen') as HTMLElement
root.scrollTop = root.scrollHeight
})
const sheet = await page.locator('footer').boundingBox()
expect(sheet!.y + sheet!.height).toBeCloseTo(844, 0)
})

await test.step('the focused card scrolls into view above the sheet', async () => {
// Pomodoro was already focused since install; move focus away and back.
await page.locator('footer button').filter({ hasText: 'Agent Chat' }).click()
await page.getByRole('button', { name: 'Open Pomodoro Timer' }).click()
await expect
.poll(async () => (await appWindow(page, 'Pomodoro Timer').boundingBox())!.y)
.toBeLessThanOrEqual(300)
})

await test.step('the taskbar sheet has touch-sized controls', async () => {
const sheet = page.locator('footer')
await expect(sheet).toBeVisible()
const box = await sheet.boundingBox()
expect(box).toBeTruthy()
expect(box!.height).toBeGreaterThanOrEqual(52) // h-14 = 56px, allow scroll rounding
// Viewport-relative: pinned by max-md:fixed, bottom edge sits at 844.
expect(box!.width).toBeGreaterThan(380)
// h-11 targets 44px touch targets on the sheet's own controls.
const buttons = sheet.locator('button')
const count = await buttons.count()
expect(count).toBeGreaterThan(0)
for (let i = 0; i < count; i++) {
const bb = await buttons.nth(i).boundingBox()
expect(bb!.height).toBeGreaterThanOrEqual(38)
}
})

await test.step('tapping the window button minimizes and restores', async () => {
// Exact match: the launcher button also contains the title but with an
// icon span and status dot around it.
const winButton = page
.locator('footer button')
.filter({ hasText: /^Pomodoro Timer$/ })
await winButton.click()
await expect(appWindow(page, 'Pomodoro Timer')).toBeHidden()
await winButton.click()
await expect(appWindow(page, 'Pomodoro Timer')).toBeVisible()
})

await test.step('the fixture persists across reload in card mode', async () => {
await page.reload()
await waitForDesktop(page)
await page.getByRole('button', { name: 'Open Pomodoro Timer' }).click()
await expect(appWindow(page, 'Pomodoro Timer').getByText('25:00')).toBeVisible()
})
})
Loading