From be47ad6fd9444df3d95f0c5e83e3488a1c04e584 Mon Sep 17 00:00:00 2001 From: manager Date: Thu, 7 May 2026 16:12:20 +0000 Subject: [PATCH 1/2] fix(mobile): iOS Safari polish across AI Atlas + global chrome Address QA mobile audit and Order's staging-build root cause: - AI Atlas: tap on touch lights up related nodes (kill pinnedSolo when no real hover), strengthen useHasHover with ontouchstart + maxTouchPoints so iPad / touch laptops are not misdetected. - AI Atlas: shrink in-canvas .dmd on iOS only (touch + supports -webkit-touch-callout) so the diamond stays proportional to the down-scaled diagram (Safari does not scale foreignObject HTML with the SVG viewBox). - AI Atlas: hash-driven Environment/Security tab now syncs in useLayoutEffect, removing the first-paint flash on deep links. - Cookie banner lifts above iPhone home-indicator via env(safe-area-inset-bottom); viewport meta gains viewport-fit=cover. - Modal overlay/background and Navbar slide-out now use dvh/dvw with vh/vw fallback so iOS Safari URL-bar collapse no longer snaps the layout. - Global body/html sets -webkit-tap-highlight-color: transparent to remove the default grey iOS tap flash. - Mobile header resets the desktop 74 px gap, clips overflow, and bounds inner row to 100dvw so the 32 px right-edge overflow at 390 px viewport is gone. - Dockerfile builder runs build:staging (APP_ENV=staging) and the runner copies .env.staging so the resulting image carries a real staging buildId instead of mislabelling itself as development. - Add .env.staging.example as the schema for the gitignored real .env.staging that must live next to the Dockerfile at build time. Co-Authored-By: Claude Opus 4.7 --- .env.staging.example | 31 ++++++++++++++++++++ Dockerfile | 9 +++++- src/components/Box/Box.module.scss | 6 +++- src/components/Header/Header.module.scss | 6 ++++ src/components/Modal/Modal.module.scss | 7 +++++ src/components/Navbar/Navbar.module.scss | 3 ++ src/components/SeoGenerator/SeoGenerator.tsx | 4 +-- src/pages/ai-atlas.tsx | 23 +++++++++++---- src/styles/ai-atlas.css | 16 ++++++++++ src/styles/globals.scss | 3 ++ 10 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 .env.staging.example diff --git a/.env.staging.example b/.env.staging.example new file mode 100644 index 0000000..cac535d --- /dev/null +++ b/.env.staging.example @@ -0,0 +1,31 @@ +# Template for .env.staging — copy this file to .env.staging next to +# the Dockerfile before running `docker build`, then fill in the real +# staging values. The actual .env.staging is gitignored so secrets +# stay host-side. The image build pipeline (Dockerfile, builder stage) +# requires this file to be present; without it the build falls +# through to .env (development config) and the resulting bundle is +# mislabelled as a development build. + +NEXT_PUBLIC_ENV=staging +NEXT_PUBLIC_INDEXING=off + +# Public — shipped to the browser bundle. +NEXT_PUBLIC_DOMAIN=https://keepsimple.administration.ae +NEXT_PUBLIC_API_KEY=__fill_in__ +NEXT_PUBLIC_STRAPI=https://strapi.keepsimple.io +NEXT_PUBLIC_MIXPANEL_TOKEN=__fill_in__ +NEXT_PUBLIC_UXCAT_API=https://staging-uxcat.keepsimple.io/ + +# Server-only — never exposed to the browser. +STRAPI_URL=https://strapi.keepsimple.io +NEXTAUTH_URL=https://keepsimple.administration.ae +NEXTAUTH_SECRET=__fill_in__ + +GOOGLE_CLIENT_ID=__fill_in__ +GOOGLE_CLIENT_SECRET=__fill_in__ + +LINKEDIN_CLIENT_ID=__fill_in__ +LINKEDIN_CLIENT_SECRET=__fill_in__ + +DISCORD_CLIENT_ID=__fill_in__ +DISCORD_CLIENT_SECRET=__fill_in__ diff --git a/Dockerfile b/Dockerfile index dd71204..ab01292 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,13 @@ RUN yarn install --frozen-lockfile FROM base AS builder COPY --from=deps /app/node_modules ./node_modules COPY . . -RUN yarn run build +# Build with APP_ENV=staging so next.config.js loadEnv() reads +# .env.staging during compilation. Without this the build silently +# falls through to .env (NEXT_PUBLIC_ENV=dev, localhost domain) and +# the resulting bundle is mislabelled as a development build. +# The Order must place .env.staging next to the Dockerfile before +# `docker build` (file is gitignored — staging secrets stay host-side). +RUN yarn run build:staging FROM base AS runner ENV NODE_ENV=production @@ -19,6 +25,7 @@ COPY --from=builder /app/public ./public COPY --from=builder /app/.next ./.next COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/.env ./.env +COPY --from=builder /app/.env.staging ./.env.staging EXPOSE 3005 CMD ["yarn", "run", "start:staging"] diff --git a/src/components/Box/Box.module.scss b/src/components/Box/Box.module.scss index 2b3bdde..23d277b 100644 --- a/src/components/Box/Box.module.scss +++ b/src/components/Box/Box.module.scss @@ -1,6 +1,9 @@ .content { position: fixed; - bottom: 15px; + /* Lift above the iPhone home-indicator on notched devices. + env() is 0 on devices without a safe area, so desktop layout is + unchanged. Pairs with `viewport-fit=cover` in SeoGenerator. */ + bottom: max(15px, env(safe-area-inset-bottom)); right: 15px; position: -webkit-fixed; height: auto; @@ -61,5 +64,6 @@ margin: 0 15px; max-width: unset; width: unset; + bottom: max(15px, env(safe-area-inset-bottom)); } } diff --git a/src/components/Header/Header.module.scss b/src/components/Header/Header.module.scss index f76e6fb..2686626 100644 --- a/src/components/Header/Header.module.scss +++ b/src/components/Header/Header.module.scss @@ -238,6 +238,12 @@ align-items: center; justify-content: center; z-index: 113; + /* Desktop sets gap: 74px which pushes children past the viewport + on phones. Reset for mobile and clip any residual overflow. */ + gap: 0; + max-width: 100vw; + max-width: 100dvw; + overflow-x: hidden; .actions { display: block; diff --git a/src/components/Modal/Modal.module.scss b/src/components/Modal/Modal.module.scss index 4178634..3bc7c2f 100644 --- a/src/components/Modal/Modal.module.scss +++ b/src/components/Modal/Modal.module.scss @@ -1,7 +1,12 @@ .overlay { display: flex; + /* dvh/dvw track the live viewport on iOS Safari (URL-bar collapse + doesn't snap layout). Falls back to vh/vw on browsers without + dvh support. */ width: 100vw; + width: 100dvw; height: 100vh; + height: 100dvh; position: fixed; top: 0; left: 0; @@ -182,7 +187,9 @@ .background { display: flex; width: 100vw; + width: 100dvw; height: 100vh; + height: 100dvh; position: fixed; top: 0; left: 0; diff --git a/src/components/Navbar/Navbar.module.scss b/src/components/Navbar/Navbar.module.scss index 7557fda..746e529 100644 --- a/src/components/Navbar/Navbar.module.scss +++ b/src/components/Navbar/Navbar.module.scss @@ -163,9 +163,12 @@ .aside { position: fixed; top: 4rem; + /* dvw avoids layout snap when iOS Safari URL bar collapses. */ left: -100vw; + left: -100dvw; height: calc(100% - 4rem); width: 100vw; + width: 100dvw; z-index: 1; background-color: #ffffff; background-image: url('/keepsimple_/assets/white-navbar-bg.png'); diff --git a/src/components/SeoGenerator/SeoGenerator.tsx b/src/components/SeoGenerator/SeoGenerator.tsx index e882d0e..d699312 100644 --- a/src/components/SeoGenerator/SeoGenerator.tsx +++ b/src/components/SeoGenerator/SeoGenerator.tsx @@ -111,7 +111,7 @@ const SeoGenerator: FC = ({ /> keep-simple | Error Page @@ -183,7 +183,7 @@ const SeoGenerator: FC = ({ ({ }); /* On touch devices Mouse* events fire synthetically on tap but never - get a leave — without this, hover state would lock on Android. */ + get a leave — without this, hover state would lock on Android. + Also: any touch capability disqualifies hover so a tap on iPad / a + touch laptop doesn't accidentally pin-solo (which suppresses the + related-entity highlight ring users expect from desktop hover). */ function useHasHover() { const [hasHover, setHasHover] = useState(false); useEffect(() => { if (typeof window === 'undefined' || !window.matchMedia) return; const mq = window.matchMedia('(hover: hover) and (pointer: fine)'); - const update = () => setHasHover(mq.matches); + const hasTouch = + 'ontouchstart' in window || + (typeof navigator !== 'undefined' && navigator.maxTouchPoints > 0); + const update = () => setHasHover(mq.matches && !hasTouch); update(); mq.addEventListener?.('change', update); return () => mq.removeEventListener?.('change', update); @@ -1482,8 +1488,12 @@ function AiAtlasApp() { }; }, []); - /* hash → view mode (initial load + back/forward) */ - useEffect(() => { + /* hash → view mode (initial load + back/forward). + useLayoutEffect runs synchronously after hydration commit and + before paint, so a deep-link to /ai-atlas#security shows the + correct tab on first paint instead of flashing 'environment' + for one frame and then snapping. */ + useLayoutEffect(() => { if (typeof window === 'undefined') return; const sync = () => { const hash = window.location.hash.replace(/^#/, '').toLowerCase(); @@ -1667,7 +1677,10 @@ function AiAtlasApp() { }; } const highlightId = linkHoverNode || focusId; - const pinnedSolo = !!focusedNode && !hoverNode && !linkHoverNode; + /* On touch devices there is no hover, so a tap should reveal the same + entity-plus-connections highlight that hovering shows on desktop. + Solo-pin only applies when real hover is available. */ + const pinnedSolo = hasHover && !!focusedNode && !hoverNode && !linkHoverNode; const onSelect = (id: string | null, mode?: string) => { if (mode === 'hover') { diff --git a/src/styles/ai-atlas.css b/src/styles/ai-atlas.css index 6c68ffc..87d8878 100644 --- a/src/styles/ai-atlas.css +++ b/src/styles/ai-atlas.css @@ -218,6 +218,22 @@ html.scroll-style-atlas::-webkit-scrollbar-thumb:hover { .dmd.blue { background: var(--blue); } .dmd.gold { background: var(--gold); } +/* iOS Safari renders foreignObject HTML at native CSS px instead of + scaling it with the SVG viewBox, so a 10 px diamond appears huge + relative to the down-scaled diagram. Shrink it on iOS only — the + diamond stays in line with its label (it is still inside .node) + and stops dwarfing the orbital geometry. macOS Safari (no touch) + and Chrome (does not recognise -webkit-touch-callout) are skipped. */ +@media (hover: none) and (pointer: coarse) { + @supports (-webkit-touch-callout: none) { + .canvas--orbital .dmd { + width: 4px; + height: 4px; + margin-right: 3px; + } + } +} + /* ---------- canvas ---------- */ .canvas--orbital { position: relative; diff --git a/src/styles/globals.scss b/src/styles/globals.scss index 37fc370..fd8d72b 100644 --- a/src/styles/globals.scss +++ b/src/styles/globals.scss @@ -2,6 +2,9 @@ body, html { padding: 0; margin: 0; + /* Kill the default grey iOS / Android tap flash. Each interactive + component owns its own pressed state via :active or aria-pressed. */ + -webkit-tap-highlight-color: transparent; } html { From 5dde8903e9c71e663f32a85a766a62bed13c3069 Mon Sep 17 00:00:00 2001 From: manager Date: Fri, 8 May 2026 06:50:13 +0000 Subject: [PATCH 2/2] fix(ai-atlas): swap iOS diamond marker for colored card outline; never dim cards on touch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diamond markers rendered inside foreignObject don't render reliably on iOS Safari. On touch-iOS, hide the diamond and color the node card's outline in the diamond's color (red/blue/gold) — same signal, reliable paint. Desktop diamonds untouched. Also: on any touch device, selecting a node no longer dims sibling cards into invisibility. Wires and ring labels still dim for focus. Co-Authored-By: Claude Opus 4.7 --- src/pages/ai-atlas.tsx | 1 + src/styles/ai-atlas.css | 33 +++++++++++++++++++++++---------- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/pages/ai-atlas.tsx b/src/pages/ai-atlas.tsx index 54d30a3..6567cdd 100644 --- a/src/pages/ai-atlas.tsx +++ b/src/pages/ai-atlas.tsx @@ -651,6 +651,7 @@ function NodeBody({ 'node', node.kind === 'filled' && 'node--filled', node.redacted && 'node--redacted', + node.diamond && `node--dmd-${node.diamond}`, active && 'is-active', highlighted && 'is-glow', dimmed && 'is-dim', diff --git a/src/styles/ai-atlas.css b/src/styles/ai-atlas.css index 87d8878..36ffb77 100644 --- a/src/styles/ai-atlas.css +++ b/src/styles/ai-atlas.css @@ -218,18 +218,24 @@ html.scroll-style-atlas::-webkit-scrollbar-thumb:hover { .dmd.blue { background: var(--blue); } .dmd.gold { background: var(--gold); } -/* iOS Safari renders foreignObject HTML at native CSS px instead of - scaling it with the SVG viewBox, so a 10 px diamond appears huge - relative to the down-scaled diagram. Shrink it on iOS only — the - diamond stays in line with its label (it is still inside .node) - and stops dwarfing the orbital geometry. macOS Safari (no touch) - and Chrome (does not recognise -webkit-touch-callout) are skipped. */ +/* iOS Safari mangles the rotated-div diamond inside foreignObject. + On touch iOS, drop the diamond marker entirely and color the card + outline in the diamond's color instead — same signal, reliable + rendering. Desktop (and non-iOS touch) keep the diamond. */ @media (hover: none) and (pointer: coarse) { @supports (-webkit-touch-callout: none) { - .canvas--orbital .dmd { - width: 4px; - height: 4px; - margin-right: 3px; + .canvas--orbital .dmd { display: none; } + .canvas--orbital .node--dmd-red { + outline: 2px solid var(--red); + outline-offset: -1px; + } + .canvas--orbital .node--dmd-blue { + outline: 2px solid var(--blue); + outline-offset: -1px; + } + .canvas--orbital .node--dmd-gold { + outline: 2px solid var(--gold); + outline-offset: -1px; } } } @@ -1077,6 +1083,13 @@ html.scroll-style-atlas::-webkit-scrollbar-thumb:hover { /* Touch devices (Android, iOS): :hover sticks after tap until the user taps elsewhere, leaving nodes visually "locked". Neutralize the hover styles where there is no real hover capability. */ +/* Touch devices: never dim node cards. Selecting one card must not + make others vanish — keep the full constellation visible at all + times. Wires and ring labels still dim for focus guidance. */ +@media (hover: none) and (pointer: coarse) { + .canvas--orbital .node.is-dim { opacity: 1; } +} + @media (hover: none) { .node:hover { background: var(--paper);