diff --git a/src/app/globals.scss b/src/app/globals.scss
index f123f11..403f1d9 100644
--- a/src/app/globals.scss
+++ b/src/app/globals.scss
@@ -20,7 +20,7 @@ html {
.word-processor {
display: flex;
flex-direction: column;
- height: 100vh;
+ height: 100dvh;
overflow: hidden;
}
@@ -443,17 +443,27 @@ html {
display: flex;
justify-content: center;
padding: 24px 24px;
+
+ @media (max-width: 815px) {
+ padding: 0;
+ }
}
// ─── Document Page (white paper) ─────────────────────────────────────────────
.document-page {
background: #fff;
- width: 816px;
+ width: min(816px, 100%);
min-height: 1056px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.55);
- padding: 96px;
+ padding: clamp(16px, 6vw, 96px);
flex-shrink: 0;
+
+ @media (max-width: 815px) {
+ width: 100%;
+ min-height: 100%;
+ box-shadow: none;
+ }
}
// ─── Document content (contentEditable) ──────────────────────────────────────
@@ -536,3 +546,147 @@ html {
opacity: 0.85;
font-style: italic;
}
+
+// ─── Dev watermark ─────────────────────────────────────────────────────────────
+
+.dev-watermark {
+ position: fixed;
+ bottom: 32px; // sit above the status bar
+ right: 12px;
+ z-index: 10000;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ color: rgba(255, 60, 60, 0.55);
+ border: 1px solid rgba(255, 60, 60, 0.35);
+ border-radius: 3px;
+ padding: 1px 5px;
+ pointer-events: none;
+ user-select: none;
+}
+
+// ─── Mobile: Ribbon chunk dropdown ──────────────────────────────────────────
+
+.word-ribbon--mobile {
+ .ribbon-panel {
+ min-height: unset;
+ padding: 4px 6px;
+ gap: 4px;
+ }
+
+ .ribbon-divider {
+ display: none;
+ }
+}
+
+.ribbon-chunk-mobile-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ padding: 3px 8px;
+ height: 26px;
+ background: transparent;
+ border: 1px solid #c0c0c0;
+ border-radius: 3px;
+ font-size: 12px;
+ color: #222;
+ cursor: pointer;
+ white-space: nowrap;
+ line-height: 1;
+
+ &:hover,
+ &--open {
+ background: #dde0e5;
+ }
+
+ svg {
+ fill: #222;
+ flex-shrink: 0;
+ }
+}
+
+.ribbon-chunk-mobile-dropdown {
+ position: fixed;
+ background: #f5f5f5;
+ border: 1px solid #c6c6c6;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
+ z-index: 9999;
+ padding: 8px;
+ min-width: 180px;
+ max-width: calc(100vw - 16px);
+ overflow-y: auto;
+ overflow-x: hidden;
+
+ // Re-apply ribbon button overrides — the portal lives outside .word-ribbon.
+ .cds--btn--ghost {
+ min-height: 22px;
+ padding: 2px 4px;
+ color: #222;
+
+ &:hover {
+ background: #dde0e5;
+ color: #222;
+ }
+
+ svg {
+ fill: #222;
+ }
+ }
+
+ .cds--btn--primary {
+ min-height: 22px;
+ padding: 2px 4px;
+ }
+
+ .ribbon-row {
+ flex-wrap: wrap;
+ gap: 3px;
+ margin-bottom: 4px;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ .ribbon-select {
+ min-width: 100%;
+ height: 28px;
+ margin-bottom: 4px;
+
+ &--size {
+ min-width: 60px;
+ width: 60px;
+ }
+ }
+
+ .ribbon-styles {
+ gap: 4px;
+ }
+
+ &__launcher {
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 4px;
+ padding-top: 4px;
+ border-top: 1px solid #c8c8c8;
+ }
+}
+
+// ─── Mobile: Title Bar ────────────────────────────────────────────────────────
+
+@media (max-width: 672px) {
+ // Hide the verbose "Autosave" label and transient status text to free up
+ // horizontal space; the toggle itself stays so the user can still control it.
+ .word-title-bar__autosave-toggle-label {
+ display: none;
+ }
+
+ .word-title-bar__autosave-status {
+ display: none;
+ }
+
+ // Shrink the centred document title so it doesn't crowd the icon buttons.
+ .word-title-bar__document-title {
+ font-size: 11px;
+ }
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index fac41db..31d1078 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -11,6 +11,15 @@ export const metadata: Metadata = {
}
};
+export const viewport = {
+ width: 'device-width',
+ initialScale: 1,
+ // Shrink the layout viewport (and all vh-sized elements) when the soft
+ // keyboard opens, instead of overlaying the content. Supported by Chrome
+ // on Android 108+; other browsers fall back gracefully.
+ interactiveWidget: 'resizes-content',
+};
+
export default function RootLayout({
children,
}: Readonly<{
@@ -18,8 +27,11 @@ export default function RootLayout({
}>) {
return (
-
+
{children}
+ {process.env.NODE_ENV === 'development' && (
+ DEV
+ )}
);
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 29e7ec0..a3ffebd 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -51,6 +51,11 @@ export default function WordProcessor() {
const [citationStyle, setCitationStyle] = useState('');
const [alignment, setAlignment] = useState('left');
const [zoom, setZoom] = useState(100);
+ const [isHydrated, setIsHydrated] = useState(false);
+
+ useEffect(() => {
+ setIsHydrated(true);
+ }, []);
// Convert tags (created by execCommand) to so
// that Word's HTML renderer handles multi-word font names (e.g. Times New Roman) reliably.
@@ -409,6 +414,11 @@ export default function WordProcessor() {
// Scale factor as a fraction (e.g. 0.8 for 80%)
const scaleFactor = zoom / 100;
+ if (!isHydrated) {
+ // Keep server and first client render identical to avoid Carbon tooltip/tab ID mismatches.
+ return ;
+ }
+
return (
{/* Title Bar */}
diff --git a/src/components/ApplicationHeader/ApplicationHeader.tsx b/src/components/ApplicationHeader/ApplicationHeader.tsx
index c4fd1ca..4bc7627 100644
--- a/src/components/ApplicationHeader/ApplicationHeader.tsx
+++ b/src/components/ApplicationHeader/ApplicationHeader.tsx
@@ -1,54 +1,54 @@
-'use client';
+"use client";
import {
-Header,
-HeaderContainer,
-HeaderName,
-HeaderNavigation,
-HeaderMenuButton,
-HeaderMenuItem,
-SkipToContent,
-SideNav,
-SideNavItems,
-HeaderSideNavItems,
-} from '@carbon/react';
+ Header,
+ HeaderContainer,
+ HeaderName,
+ HeaderNavigation,
+ HeaderMenuButton,
+ HeaderMenuItem,
+ SkipToContent,
+ SideNav,
+ SideNavItems,
+ HeaderSideNavItems,
+} from "@carbon/react";
-import Link from 'next/link';
+import Link from "next/link";
const ApplicationHeader = () => (
-
(
-
-
-
-
-Template
-
-
-
-Google
-
-
-
-
-
-
-Google
-
-
-
-
-
-)}
-/>
+ (
+
+
+
+
+ Template
+
+
+
+ Google
+
+
+
+
+
+
+ Google
+
+
+
+
+
+ )}
+ />
);
export default ApplicationHeader;
diff --git a/src/components/DocumentEditor/DocumentEditor.tsx b/src/components/DocumentEditor/DocumentEditor.tsx
index 3f340cc..370a04e 100644
--- a/src/components/DocumentEditor/DocumentEditor.tsx
+++ b/src/components/DocumentEditor/DocumentEditor.tsx
@@ -42,6 +42,35 @@ const DocumentEditor = forwardRef(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref]);
+ // Scroll the cursor into view when the soft keyboard opens on mobile.
+ // The Visual Viewport API fires a "resize" event whenever the visible area
+ // shrinks (e.g. keyboard appearing). If the cursor ends up below the new
+ // viewport bottom we nudge it back into view.
+ useEffect(() => {
+ const vv = window.visualViewport;
+ if (!vv) return;
+
+ const scrollCursorIntoView = () => {
+ const sel = window.getSelection();
+ if (!sel || !sel.rangeCount) return;
+
+ const rects = sel.getRangeAt(0).getClientRects();
+ if (!rects.length) return;
+ const cursorRect = rects[rects.length - 1];
+
+ if (cursorRect.bottom > vv.offsetTop + vv.height) {
+ const focusEl =
+ sel.focusNode?.nodeType === Node.TEXT_NODE
+ ? sel.focusNode.parentElement
+ : (sel.focusNode as Element | null);
+ focusEl?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
+ }
+ };
+
+ vv.addEventListener('resize', scrollCursorIntoView);
+ return () => vv.removeEventListener('resize', scrollCursorIntoView);
+ }, []);
+
const handleInput = (e: React.FormEvent) => {
const el = e.currentTarget;
const text = el.innerText;
diff --git a/src/components/Ribbon/Ribbon.tsx b/src/components/Ribbon/Ribbon.tsx
index b2f0c57..33c98a7 100644
--- a/src/components/Ribbon/Ribbon.tsx
+++ b/src/components/Ribbon/Ribbon.tsx
@@ -59,6 +59,22 @@ interface RibbonProps {
onCitationStyleChange: (style: string) => void;
}
+// ─── Mobile context / hook ────────────────────────────────────────────────────
+
+const RibbonMobileContext = React.createContext(false);
+
+const useMobileRibbon = () => {
+ const [isMobile, setIsMobile] = React.useState(false);
+ React.useEffect(() => {
+ const mq = window.matchMedia('(max-width: 815px)');
+ setIsMobile(mq.matches);
+ const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
+ mq.addEventListener('change', handler);
+ return () => mq.removeEventListener('change', handler);
+ }, []);
+ return isMobile;
+};
+
// ─── Helper sub-components ────────────────────────────────────────────────────
const RibbonChunk = ({
@@ -69,15 +85,88 @@ const RibbonChunk = ({
label: string;
children: React.ReactNode;
launcher?: React.ReactNode;
-}) => (
-
-
{children}
-
-
{label}
- {launcher &&
{launcher}}
+}) => {
+ const isMobile = React.useContext(RibbonMobileContext);
+ const [open, setOpen] = React.useState(false);
+ const [menuPos, setMenuPos] = React.useState({ top: 0, left: 0 });
+ const btnRef = React.useRef
(null);
+ const menuRef = React.useRef(null);
+
+ React.useEffect(() => {
+ if (!open) return;
+ const handleOutside = (e: MouseEvent) => {
+ const t = e.target as Node;
+ if (!btnRef.current?.contains(t) && !menuRef.current?.contains(t)) {
+ setOpen(false);
+ }
+ };
+ document.addEventListener('mousedown', handleOutside);
+ return () => document.removeEventListener('mousedown', handleOutside);
+ }, [open]);
+
+ if (isMobile) {
+ const handleToggle = () => {
+ if (!open && btnRef.current) {
+ const rect = btnRef.current.getBoundingClientRect();
+ const MARGIN = 8;
+ // MIN_WIDTH matches the CSS min-width of .ribbon-chunk-mobile-dropdown.
+ // Using it as a lower-bound estimate lets us pre-clamp right overflow
+ // synchronously, without needing a post-render measurement.
+ const MIN_WIDTH = 180;
+ const vw = window.innerWidth;
+ const top = rect.bottom + 2;
+ let left = rect.left;
+ if (left + MIN_WIDTH + MARGIN > vw) left = vw - MIN_WIDTH - MARGIN;
+ if (left < MARGIN) left = MARGIN;
+ setMenuPos({ top, left });
+ }
+ setOpen((o) => !o);
+ };
+
+ return (
+ <>
+
+ {open && typeof document !== 'undefined' && ReactDOM.createPortal(
+
+ {children}
+ {launcher &&
{launcher}
}
+
,
+ document.body
+ )}
+ >
+ );
+ }
+
+ return (
+
+
{children}
+
+ {label}
+ {launcher && {launcher}}
+
-
-);
+ );
+};
const RibbonDivider = () =>
;
@@ -290,9 +379,11 @@ const Ribbon = ({
onCitationStyleChange,
}: RibbonProps) => {
const fmt = (cmd: string, val?: string) => () => onFormat(cmd, val);
+ const isMobile = useMobileRibbon();
return (
-
+
+
Home
@@ -770,6 +861,7 @@ const Ribbon = ({
+
);
};