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
24 changes: 19 additions & 5 deletions apps/desktop/src/renderer/src/components/PhoneFrame.test.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,34 @@
import { describe, expect, it } from 'vitest';
import { PHONE_FRAME_SIZING } from './PhoneFrame';
import { PHONE_FRAME_SIZING, PHONE_FRAME_TEST_IDS } from './PhoneFrame';

describe('PhoneFrame sizing contract', () => {
it('uses iPhone-reference 375x812 screen dimensions', () => {
expect(PHONE_FRAME_SIZING.expectedScreenWidthPx).toBe(375);
expect(PHONE_FRAME_SIZING.expectedScreenHeightPx).toBe(812);
});

it('keeps total frame size near iPhone 396x844 (within 8px bezel)', () => {
expect(PHONE_FRAME_SIZING.expectedFrameWidthPx).toBe(391);
expect(PHONE_FRAME_SIZING.expectedFrameHeightPx).toBe(828);
it('uses a thin bezel so artifacts are not visually clipped', () => {
expect(PHONE_FRAME_SIZING.expectedBezelWidthPx).toBe(3);
expect(PHONE_FRAME_SIZING.expectedFrameWidthPx).toBe(381);
expect(PHONE_FRAME_SIZING.expectedFrameHeightPx).toBe(818);
});

it('references shared design tokens, not hard-coded pixels', () => {
expect(PHONE_FRAME_SIZING.screenWidthVar).toBe('--size-preview-mobile-width');
expect(PHONE_FRAME_SIZING.screenHeightVar).toBe('--size-preview-mobile-height');
expect(PHONE_FRAME_SIZING.borderWidthVar).toBe('--border-width-strong');
expect(PHONE_FRAME_SIZING.bezelWidthVar).toBe('--border-width-phone-bezel');
});

it('paints the body with a dedicated phone-body token, not the app surface', () => {
expect(PHONE_FRAME_SIZING.bodyColorVar).toBe('--color-phone-body');
expect(PHONE_FRAME_SIZING.bodyColorVar).not.toBe('--color-surface');
expect(PHONE_FRAME_SIZING.bodyColorVar).not.toBe('--color-background');
});

it('exposes a dynamic island element via tokens and a stable test id', () => {
expect(PHONE_FRAME_SIZING.islandWidthVar).toBe('--size-preview-mobile-island-width');
expect(PHONE_FRAME_SIZING.islandHeightVar).toBe('--size-preview-mobile-island-height');
expect(PHONE_FRAME_SIZING.islandColorVar).toBe('--color-phone-island');
expect(PHONE_FRAME_TEST_IDS.dynamicIsland).toBe('phone-frame-dynamic-island');
});
});
82 changes: 46 additions & 36 deletions apps/desktop/src/renderer/src/components/PhoneFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,78 +6,88 @@ interface PhoneFrameProps {

/**
* Pure-data sizing contract for the iPhone-style bezel. Exported so unit
* tests can verify the frame stays at iPhone-reference dimensions without
* needing a DOM environment.
* tests can verify the frame stays at iPhone-reference dimensions and
* uses the correct design tokens without needing a DOM environment.
*/
export const PHONE_FRAME_SIZING = {
screenWidthVar: '--size-preview-mobile-width',
screenHeightVar: '--size-preview-mobile-height',
borderWidthVar: '--border-width-strong',
bezelWidthVar: '--border-width-phone-bezel',
bodyColorVar: '--color-phone-body',
islandColorVar: '--color-phone-island',
islandWidthVar: '--size-preview-mobile-island-width',
islandHeightVar: '--size-preview-mobile-island-height',
expectedScreenWidthPx: 375,
expectedScreenHeightPx: 812,
expectedBorderWidthPx: 8,
expectedBezelWidthPx: 3,
get expectedFrameWidthPx(): number {
return this.expectedScreenWidthPx + this.expectedBorderWidthPx * 2;
return this.expectedScreenWidthPx + this.expectedBezelWidthPx * 2;
},
get expectedFrameHeightPx(): number {
return this.expectedScreenHeightPx + this.expectedBorderWidthPx * 2;
return this.expectedScreenHeightPx + this.expectedBezelWidthPx * 2;
},
} as const;

export const PHONE_FRAME_TEST_IDS = {
body: 'phone-frame-body',
dynamicIsland: 'phone-frame-dynamic-island',
} as const;

/**
* Renders an iPhone-style bezel around its child iframe.
* All measurements are derived from design tokens (CSS custom properties).
* No px or color hard-codes — see packages/ui/src/tokens.css.
* Renders an iPhone-style device shell around its child iframe.
*
* The screen area has fixed pixel dimensions; child iframes should fill
* 100% of that area (do not set their own pixel width/height).
* Single-layer body in deep space-gray (intentionally distinct from the
* cream app background so the device reads as a physical object), a thin
* bezel, and a centered Dynamic Island. The screen rounds inward by
* (radius-phone − bezel) so artifact content isn't clipped at the corners.
*/
export function PhoneFrame({ children }: PhoneFrameProps): ReactElement {
return (
<div
data-testid={PHONE_FRAME_TEST_IDS.body}
style={{
display: 'inline-flex',
flexDirection: 'column',
position: 'relative',
flexShrink: 0,
boxSizing: 'content-box',
padding: 'var(--border-width-phone-bezel)',
borderRadius: 'var(--radius-phone)',
border: 'var(--border-width-strong) solid var(--color-border-strong)',
boxShadow: 'var(--shadow-elevated), var(--shadow-inset-soft)',
background: 'var(--color-surface)',
overflow: 'hidden',
background: 'var(--color-phone-body)',
boxShadow: 'var(--shadow-elevated)',
}}
>
{/* Notch */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: '50%',
transform: 'translateX(-50%)',
width: 'var(--size-preview-mobile-notch-width)',
height: 'var(--size-preview-mobile-notch-height)',
background: 'var(--color-border-strong)',
borderBottomLeftRadius: 'var(--radius-lg)',
borderBottomRightRadius: 'var(--radius-lg)',
zIndex: 2,
pointerEvents: 'none',
}}
/>
{/* Screen area — fixed dimensions; iframe child fills 100% */}
{/* Screen — fixed dimensions; iframe child fills 100% */}
<div
style={{
position: 'relative',
width: 'var(--size-preview-mobile-width)',
height: 'var(--size-preview-mobile-height)',
flexShrink: 0,
overflow: 'hidden',
borderRadius: 'calc(var(--radius-phone) - var(--border-width-strong))',
background: 'var(--color-artifact-bg)',
borderRadius: 'calc(var(--radius-phone) - var(--border-width-phone-bezel))',
}}
>
{children}
</div>
{/* Dynamic Island — pill, floats over the screen top */}
<div
data-testid={PHONE_FRAME_TEST_IDS.dynamicIsland}
aria-hidden="true"
style={{
position: 'absolute',
top: 'var(--space-2)',
left: '50%',
transform: 'translateX(-50%)',
width: 'var(--size-preview-mobile-island-width)',
height: 'var(--size-preview-mobile-island-height)',
background: 'var(--color-phone-island)',
borderRadius: 'var(--radius-full)',
zIndex: 2,
pointerEvents: 'none',
}}
/>
{/* Home indicator */}
<div
aria-hidden="true"
Expand All @@ -88,9 +98,9 @@ export function PhoneFrame({ children }: PhoneFrameProps): ReactElement {
transform: 'translateX(-50%)',
width: 'var(--size-preview-mobile-home-indicator-width)',
height: 'var(--size-preview-mobile-home-indicator-height)',
background: 'var(--color-border-strong)',
background: 'var(--color-phone-island)',
borderRadius: 'var(--radius-full)',
opacity: 0.65,
opacity: 0.5,
zIndex: 2,
pointerEvents: 'none',
}}
Expand Down
1 change: 0 additions & 1 deletion apps/desktop/src/renderer/src/components/PreviewPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,6 @@ export function PreviewPane({ onPickStarter }: PreviewPaneProps) {
body = (
<div className="min-h-full p-6 flex flex-col items-center justify-center overflow-auto">
<div className="relative inline-flex">
<div className={COMMENT_HINT_CLASS}>{t('preview.clickToComment')}</div>
<PhoneFrame>{iframe}</PhoneFrame>
<InlineCommentComposer />
</div>
Expand Down
14 changes: 12 additions & 2 deletions packages/ui/src/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -162,12 +162,18 @@

/* Border width */
--border-width-strong: 8px;
--border-width-phone-bezel: 3px;

/* Phone body — deep space gray, intentionally distinct from app cream bg
so the device reads as a physical object, not melting into the canvas. */
--color-phone-body: oklch(0.22 0.005 280);
--color-phone-island: oklch(0.08 0 0);

/* Preview viewport sizes */
--size-preview-mobile-width: 375px;
--size-preview-mobile-height: 812px;
--size-preview-mobile-notch-width: 120px;
--size-preview-mobile-notch-height: 30px;
--size-preview-mobile-island-width: 95px;
--size-preview-mobile-island-height: 28px;
--size-preview-mobile-home-indicator-width: 134px;
--size-preview-mobile-home-indicator-height: 5px;
--size-preview-tablet-width: 768px;
Expand Down Expand Up @@ -241,6 +247,10 @@ pre,
--color-toast-error: #d96050;
--color-overlay: oklch(0 0 0 / 0.6);

/* Phone body — slightly lighter than dark bg so the device still reads as an object. */
--color-phone-body: oklch(0.32 0.005 280);
--color-phone-island: oklch(0.05 0 0);

--shadow-soft: 0 1px 2px oklch(0 0 0 / 0.3);
--shadow-card: 0 1px 2px oklch(0 0 0 / 0.25), 0 4px 16px oklch(0 0 0 / 0.35);
--shadow-elevated: 0 2px 4px oklch(0 0 0 / 0.3), 0 12px 32px oklch(0 0 0 / 0.5);
Expand Down
Loading