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); @@ -645,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', @@ -1482,8 +1489,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 +1678,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..36ffb77 100644 --- a/src/styles/ai-atlas.css +++ b/src/styles/ai-atlas.css @@ -218,6 +218,28 @@ html.scroll-style-atlas::-webkit-scrollbar-thumb:hover { .dmd.blue { background: var(--blue); } .dmd.gold { background: var(--gold); } +/* 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 { 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; + } + } +} + /* ---------- canvas ---------- */ .canvas--orbital { position: relative; @@ -1061,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); 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 {