Skip to content
Open
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
2 changes: 1 addition & 1 deletion apps/web/components/ui/preview-notification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ function PreviewNotificationContent() {
bottom: 0,
left: 0,
right: 0,
zIndex: 9999,
zIndex: "preview",
backgroundColor: "warning.200",
color: "black",
padding: 3,
Expand Down
49 changes: 31 additions & 18 deletions apps/web/components/widgets/now-playing-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { trackNowPlayingClick } from "@httpjpg/analytics";
import { NowPlaying, useNowPlaying } from "@httpjpg/now-playing";
import { zIndex } from "@httpjpg/tokens";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
Expand Down Expand Up @@ -31,24 +32,36 @@ function NowPlayingWidgetComponent() {
};

const content = (
<button type="button" onClick={handleClick} style={{ all: "unset" }}>
<NowPlaying
title={isLoading ? "Loading..." : data?.title || "╱╱ #welovemusic ╱╱"}
artist={
isLoading ? "..." : data?.artist || "⋄ ⋄ ⋄ (spotify(none)) ⋄ ⋄ ⋄"
}
artwork={
data?.artwork ||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect fill='%23a3a3a3' width='100' height='100'/%3E%3Ctext x='50' y='50' font-family='monospace' font-size='40' text-anchor='middle' dy='.3em' fill='white'%3E♪%3C/text%3E%3C/svg%3E"
}
isPlaying={data?.isPlaying || false}
isLoading={isLoading}
autoExtractColor={!!data && !isLoading}
vibrantColor={
!data || isLoading ? "rgba(163, 163, 163, 0.6)" : undefined
}
textColor={!data || isLoading ? "white" : undefined}
/>
<button
type="button"
onClick={handleClick}
style={{
all: "unset",
position: "fixed",
inset: 0,
pointerEvents: "none",
zIndex: zIndex.floating,
}}
>
<div style={{ pointerEvents: "auto" }}>
<NowPlaying
title={isLoading ? "Loading..." : data?.title || "╱╱ #welovemusic ╱╱"}
artist={
isLoading ? "..." : data?.artist || "⋄ ⋄ ⋄ (spotify(none)) ⋄ ⋄ ⋄"
}
artwork={
data?.artwork ||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect fill='%23a3a3a3' width='100' height='100'/%3E%3Ctext x='50' y='50' font-family='monospace' font-size='40' text-anchor='middle' dy='.3em' fill='white'%3E♪%3C/text%3E%3C/svg%3E"
}
isPlaying={data?.isPlaying || false}
isLoading={isLoading}
autoExtractColor={!!data && !isLoading}
vibrantColor={
!data || isLoading ? "rgba(163, 163, 163, 0.6)" : undefined
}
textColor={!data || isLoading ? "white" : undefined}
/>
</div>
</button>
Comment on lines +35 to 65
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reconsider the full-viewport button element for accessibility and semantics.

Wrapping the entire viewport in a <button> element creates several concerns:

  1. Semantic HTML: A button spanning the full screen with pointerEvents: none is semantically incorrect, even though pointer events are disabled on the outer element.

  2. Keyboard accessibility: The button remains focusable, which means keyboard users could tab to it and see a focus ring on the entire viewport, creating a confusing experience.

  3. Code consistency: Inline styles are used here instead of the Box component's css prop pattern used throughout the codebase.

♿ Proposed architectural improvements

Option 1: Non-interactive outer container (recommended)

+ import { Box } from "@httpjpg/ui";
+
  const content = (
-    <button
-      type="button"
-      onClick={handleClick}
-      style={{
-        all: "unset",
+    <Box
+      css={{
         position: "fixed",
         inset: 0,
         pointerEvents: "none",
         zIndex: zIndex.floating,
       }}
     >
-      <div style={{ pointerEvents: "auto" }}>
+      <Box
+        as="button"
+        type="button"
+        onClick={handleClick}
+        css={{ 
+          all: "unset",
+          pointerEvents: "auto",
+          cursor: "pointer",
+        }}
+      >
         <NowPlaying
           title={isLoading ? "Loading..." : data?.title || "╱╱ #welovemusic ╱╱"}
           artist={
             isLoading ? "..." : data?.artist || "⋄ ⋄ ⋄ (spotify(none)) ⋄ ⋄ ⋄"
           }
           {/* ...rest of props... */}
         />
-      </div>
-    </button>
+      </Box>
+    </Box>
   );

This approach:

  • Uses a non-interactive Box for the viewport overlay
  • Makes only the NowPlaying widget area interactive and focusable
  • Follows the codebase's Box component pattern
  • Maintains proper keyboard navigation semantics
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
type="button"
onClick={handleClick}
style={{
all: "unset",
position: "fixed",
inset: 0,
pointerEvents: "none",
zIndex: zIndex.floating,
}}
>
<div style={{ pointerEvents: "auto" }}>
<NowPlaying
title={isLoading ? "Loading..." : data?.title || "╱╱ #welovemusic ╱╱"}
artist={
isLoading ? "..." : data?.artist || "⋄ ⋄ ⋄ (spotify(none)) ⋄ ⋄ ⋄"
}
artwork={
data?.artwork ||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect fill='%23a3a3a3' width='100' height='100'/%3E%3Ctext x='50' y='50' font-family='monospace' font-size='40' text-anchor='middle' dy='.3em' fill='white'%3E♪%3C/text%3E%3C/svg%3E"
}
isPlaying={data?.isPlaying || false}
isLoading={isLoading}
autoExtractColor={!!data && !isLoading}
vibrantColor={
!data || isLoading ? "rgba(163, 163, 163, 0.6)" : undefined
}
textColor={!data || isLoading ? "white" : undefined}
/>
</div>
</button>
<Box
css={{
position: "fixed",
inset: 0,
pointerEvents: "none",
zIndex: zIndex.floating,
}}
>
<Box
as="button"
type="button"
onClick={handleClick}
css={{
all: "unset",
pointerEvents: "auto",
cursor: "pointer",
}}
>
<NowPlaying
title={isLoading ? "Loading..." : data?.title || "╱╱ #welovemusic ╱╱"}
artist={
isLoading ? "..." : data?.artist || "⋄ ⋄ ⋄ (spotify(none)) ⋄ ⋄ ⋄"
}
artwork={
data?.artwork ||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect fill='%23a3a3a3' width='100' height='100'/%3E%3Ctext x='50' y='50' font-family='monospace' font-size='40' text-anchor='middle' dy='.3em' fill='white'%3E♪%3C/text%3E%3C/svg%3E"
}
isPlaying={data?.isPlaying || false}
isLoading={isLoading}
autoExtractColor={!!data && !isLoading}
vibrantColor={
!data || isLoading ? "rgba(163, 163, 163, 0.6)" : undefined
}
textColor={!data || isLoading ? "white" : undefined}
/>
</Box>
</Box>
🤖 Prompt for AI Agents
In @apps/web/components/widgets/now-playing-widget.tsx around lines 35 - 65, The
outer full-viewport <button> (with onClick=handleClick) is semantically and
accessibly wrong; replace it with a non-interactive Box (or div) that uses the
component's css prop for the overlay positioning/zIndex instead of inline
styles, move the onClick/handleClick and focusable behavior to the inner
interactive container that wraps NowPlaying (give that wrapper tabIndex=0 or
make it a real button/anchor and keep pointerEvents enabled), ensure only the
NowPlaying area is keyboard-focusable (no giant focus ring on the viewport),
preserve zIndex/inset positioning and the NowPlaying props
(title/artist/artwork/isPlaying/isLoading/autoExtractColor/vibrantColor/textColor)
and remove pointerEvents: "none" from the outer element.

);

Expand Down
2 changes: 1 addition & 1 deletion apps/web/components/widgets/psn-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const PSNCard = ({ username }: PSNCardProps) => {
position: "fixed",
top: 0,
left: 0,
zIndex: 40,
zIndex: "widget",
width: "250px",
display: "none",
lg: {
Expand Down
3 changes: 2 additions & 1 deletion packages/consent/src/components/cookie-banner.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { zIndex } from "@httpjpg/tokens";
import { Box, Button } from "@httpjpg/ui";
import { useEffect, useState } from "react";
import { getConsent, hasConsent, setConsent } from "../consent";
Expand Down Expand Up @@ -117,7 +118,7 @@ export function CookieBanner({
bottom: 0,
left: 0,
right: 0,
zIndex: 99999,
zIndex: zIndex.cookieBanner,
backgroundColor: "white",
color: "black",
padding: "24px 16px",
Expand Down
1 change: 0 additions & 1 deletion packages/now-playing/src/now-playing-loading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ export const NowPlayingLoading = ({
width: config.width,
height: config.height,
cursor: "grab",
zIndex: 9999,
touchAction: "none",
userSelect: "none",
...style,
Expand Down
1 change: 0 additions & 1 deletion packages/now-playing/src/now-playing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,6 @@ export const NowPlaying = ({
width: config.width,
height: config.height,
cursor: "grab",
zIndex: 9999,
touchAction: "none",
userSelect: "none",
}}
Expand Down
185 changes: 135 additions & 50 deletions packages/storyblok-ui/src/components/work-list/SbWorkList.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
"use client";

// TODO: Fix Storyblok component registration
// This component exists in code but throws "Component work-list doesn't exist" in CMS
// Need to run: pnpm --filter @httpjpg/storyblok-sync run sync
// Or manually create component in Storyblok dashboard

import { renderStoryblokRichText } from "@httpjpg/storyblok-richtext";
import { WorkList } from "@httpjpg/ui";
import { memo } from "react";
import { memo, type ReactNode, useRef } from "react";
import { mapColorToToken } from "../../lib/token-mapping";
import { useStoryblokEditable } from "../../lib/use-storyblok-editable";

export interface SbWorkListProps {
Expand Down Expand Up @@ -39,6 +35,11 @@ export interface SbWorkListProps {
};
}
>; // Array of story UUIDs or resolved stories
gap?: number;
showDividers?: boolean;
dividerPattern?: string;
dividerColor?: string;
dividerSpacing?: string;
};
/**
* Base URL for work links
Expand All @@ -56,63 +57,147 @@ export const SbWorkList = memo(function SbWorkList({
baseUrl = "/work",
}: SbWorkListProps) {
const editableProps = useStoryblokEditable(blok);
const { work } = blok;
const lastWorkItemsRef = useRef<any[]>([]);

const {
work,
gap,
showDividers,
dividerPattern,
dividerColor,
dividerSpacing,
} = blok || {};
const showDividersValue = showDividers === true;

// Filter out UUIDs and only keep resolved stories (objects)
// Filter out UUIDs and only keep resolved stories
const workStories = (work || []).filter(
(item): item is Exclude<typeof item, string> => typeof item === "object",
(item): item is Exclude<typeof item, string> =>
item != null && typeof item === "object" && "slug" in item,
);

// Use cached items during Storyblok reload (Visual Editor stability)
if (!workStories || workStories.length === 0) {
return null;
if (lastWorkItemsRef.current.length > 0 && work && work.length > 0) {
return (
<div {...editableProps} key={blok._uid}>
<WorkList
works={lastWorkItemsRef.current}
gap={gap}
showDividers={showDividersValue}
dividerProps={
showDividersValue
? {
variant: "ascii",
pattern: dividerPattern || "⋆。°✩ ・ ✦ ・ ✧ ・ ✦ ・ ✩°。⋆",
color: mapColorToToken(dividerColor) || "neutral.200",
spacing: dividerSpacing || "3",
}
: undefined
}
/>
</div>
);
}

return (
<div {...editableProps} key={blok._uid}>
<div
style={{
padding: "1rem",
border: "2px dashed #ccc",
textAlign: "center",
}}
>
No work items selected. Add work items in Storyblok.
</div>
</div>
);
}

// Transform Storyblok work data to WorkList format
const workItems = workStories.map((item) => {
// Use date_end if exists (for events/exhibitions with timespan),
// otherwise use date field, fallback to first_published_at
const dateString =
item.content?.date_end ||
item.content?.date ||
item.first_published_at ||
item.published_at ||
item.created_at;
const workItems = workStories
.map((item) => {
if (!item.slug) return null;

// Render rich text description if available
const description = item.content?.description
? renderStoryblokRichText(item.content.description)
: undefined;
const dateString =
item.content?.date_end ||
item.content?.date ||
item.first_published_at ||
item.published_at ||
item.created_at ||
new Date().toISOString();

let description: ReactNode;
if (item.content?.description) {
try {
description = renderStoryblokRichText(item.content.description);
} catch {
// Silently fail - description is optional
}
}

return {
id: item.slug,
slug: item.slug,
title: item.content?.title || item.name,
description,
images: (item.content?.images || []).map((img) => {
// Check content_type first (for Storyblok assets)
const hasVideoContentType = img.content_type?.startsWith("video/");
// Fallback: Check file extension (for external URLs)
const hasVideoExtension = /\.(mp4|webm|ogg|mov|avi|mkv)(\?|$)/i.test(
img.filename || "",
);
const isVideo = hasVideoContentType || hasVideoExtension;
return {
id: item.slug,
slug: item.slug,
title: item.content?.title || item.name || "Untitled",
description,
images: (item.content?.images || [])
.filter((img) => img?.filename)
.map((img) => {
const hasVideoContentType = img.content_type?.startsWith("video/");
const hasVideoExtension =
/\.(mp4|webm|ogg|mov|avi|mkv)(\?|$)/i.test(img.filename || "");
const isVideo = hasVideoContentType || hasVideoExtension;

return {
url: isVideo ? "" : img.filename || "",
alt: img.alt || item.content?.title || item.name,
copyright: img.copyright,
focus: img.focus,
videoUrl: isVideo ? img.filename : undefined,
};
}),
date: dateString,
baseUrl,
};
});
return {
url: isVideo ? "" : img.filename || "",
alt: img.alt || item.content?.title || item.name || "Image",
copyright: img.copyright,
focus: img.focus,
videoUrl: isVideo ? img.filename : undefined,
};
}),
date: dateString,
baseUrl,
};
})
.filter((item): item is NonNullable<typeof item> => item !== null);

if (workItems.length === 0) {
return (
<div {...editableProps} key={blok._uid}>
<div
style={{
padding: "1rem",
border: "2px dashed #f00",
textAlign: "center",
}}
>
⚠️ Error loading work items.
</div>
</div>
);
}

lastWorkItemsRef.current = workItems;

return (
<div {...editableProps}>
<WorkList works={workItems} />
<div {...editableProps} key={blok._uid}>
<WorkList
works={workItems}
gap={gap}
showDividers={showDividersValue}
dividerProps={
showDividersValue
? {
variant: "ascii",
pattern: dividerPattern || "⋆。°✩ ・ ✦ ・ ✧ ・ ✦ ・ ✩°。⋆",
color: mapColorToToken(dividerColor) || "neutral.200",
spacing: dividerSpacing || "3",
}
: undefined
}
/>
</div>
);
});
3 changes: 3 additions & 0 deletions packages/tokens/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export {
remToPx,
responsiveSpacing,
} from "./utils";
export { type ZIndexKey, type ZIndexValue, zIndex } from "./z-index";

import { borderRadius } from "./border-radius";
import { colors } from "./colors";
Expand All @@ -31,6 +32,7 @@ import { sizes } from "./sizes";
import { spacing } from "./spacing";
import { transitions } from "./transitions";
import { typography } from "./typography";
import { zIndex } from "./z-index";

/**
* Complete design tokens object
Expand All @@ -50,6 +52,7 @@ export const tokens = {
opacity,
transitions,
sizes,
zIndex,
} as const;

export type Tokens = typeof tokens;
Loading