diff --git a/.gitignore b/.gitignore
index c9fdd6f0b0..ab2810b1e8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -220,3 +220,6 @@ $RECYCLE.BIN/
application/upload/**
!application/upload/**/
!application/upload/**/.gitkeep
+
+# ローカル Lighthouse 計測の出力(任意)
+scoring-tool/lighthouse*.json
diff --git a/Dockerfile b/Dockerfile
index 2c95811428..4c48a1d373 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -21,7 +21,7 @@ RUN --mount=type=cache,target=/pnpm/store pnpm install --frozen-lockfile
COPY ./application .
-RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm build
+RUN NODE_OPTIONS="--max-old-space-size=4096" NODE_ENV=production pnpm build
RUN --mount=type=cache,target=/pnpm/store CI=true pnpm install --frozen-lockfile --prod --filter @web-speed-hackathon-2026/server
diff --git a/application/README.md b/application/README.md
index f03680422d..9758dfa1af 100644
--- a/application/README.md
+++ b/application/README.md
@@ -14,10 +14,14 @@ CaX のアプリケーションコードです。
### ビルド・起動
-1. アプリケーションをビルドします
+1. アプリケーションをビルドします(本番相当の最適化。Docker ビルドと同じ `NODE_ENV=production` です)
- ```bash
pnpm run build
```
+ - 以前の「非最小化・`mode: none`」に近いビルドが必要な場合のみ(デバッグ用):
+ - ```bash
+ pnpm --filter @web-speed-hackathon-2026/client run build:dev
+ ```
2. サーバーを起動します
- ```bash
pnpm run start
diff --git a/application/client/babel.config.js b/application/client/babel.config.js
index c3c574591a..e2d3b43018 100644
--- a/application/client/babel.config.js
+++ b/application/client/babel.config.js
@@ -1,19 +1,22 @@
+const isProduction = process.env.NODE_ENV === "production";
+
module.exports = {
presets: [
["@babel/preset-typescript"],
[
"@babel/preset-env",
{
- targets: "ie 11",
+ // 本番はレギュレーションどおり最新 Chrome 想定。modules:false で webpack が import() 分割できるようにする
+ targets: isProduction ? { chrome: "120" } : "ie 11",
corejs: "3",
- modules: "commonjs",
+ modules: isProduction ? false : "commonjs",
useBuiltIns: false,
},
],
[
"@babel/preset-react",
{
- development: true,
+ development: !isProduction,
runtime: "automatic",
},
],
diff --git a/application/client/package.json b/application/client/package.json
index 9f8e80a6a8..2014e72189 100644
--- a/application/client/package.json
+++ b/application/client/package.json
@@ -5,7 +5,8 @@
"license": "MPL-2.0",
"author": "CyberAgent, Inc.",
"scripts": {
- "build": "NODE_ENV=development webpack",
+ "build": "NODE_ENV=production webpack",
+ "build:dev": "NODE_ENV=development webpack",
"typecheck": "tsc"
},
"dependencies": {
@@ -57,6 +58,7 @@
"@babel/preset-env": "7.28.3",
"@babel/preset-react": "7.27.1",
"@babel/preset-typescript": "7.27.1",
+ "@tailwindcss/postcss": "4.2.2",
"@tsconfig/strictest": "2.0.8",
"@types/bluebird": "3.5.42",
"@types/common-tags": "1.8.4",
@@ -83,6 +85,7 @@
"postcss-loader": "8.2.0",
"postcss-preset-env": "10.4.0",
"react-markdown": "10.1.0",
+ "tailwindcss": "4.2.2",
"typescript": "5.9.3",
"webpack": "5.102.1",
"webpack-cli": "6.0.1",
diff --git a/application/client/postcss.config.js b/application/client/postcss.config.js
index d7ee920b94..21c397a6ff 100644
--- a/application/client/postcss.config.js
+++ b/application/client/postcss.config.js
@@ -1,11 +1,5 @@
-const postcssImport = require("postcss-import");
-const postcssPresetEnv = require("postcss-preset-env");
-
module.exports = {
plugins: [
- postcssImport(),
- postcssPresetEnv({
- stage: 3,
- }),
+ require("@tailwindcss/postcss")(),
],
};
diff --git a/application/client/src/components/application/SearchPage.tsx b/application/client/src/components/application/SearchPage.tsx
index e99045de45..1af5519dd0 100644
--- a/application/client/src/components/application/SearchPage.tsx
+++ b/application/client/src/components/application/SearchPage.tsx
@@ -9,7 +9,6 @@ import {
} from "@web-speed-hackathon-2026/client/src/search/services";
import { SearchFormData } from "@web-speed-hackathon-2026/client/src/search/types";
import { validate } from "@web-speed-hackathon-2026/client/src/search/validation";
-import { analyzeSentiment } from "@web-speed-hackathon-2026/client/src/utils/negaposi_analyzer";
import { Button } from "../foundation/Button";
@@ -53,17 +52,20 @@ const SearchPageComponent = ({
}
let isMounted = true;
- analyzeSentiment(parsed.keywords)
- .then((result) => {
+ (async () => {
+ try {
+ // 重い sentiment 分析ライブラリは、初期描画後に遅延ロードする
+ const mod = await import("@web-speed-hackathon-2026/client/src/utils/negaposi_analyzer");
+ const result = await mod.analyzeSentiment(parsed.keywords);
if (isMounted) {
setIsNegative(result.label === "negative");
}
- })
- .catch(() => {
+ } catch {
if (isMounted) {
setIsNegative(false);
}
- });
+ }
+ })();
return () => {
isMounted = false;
diff --git a/application/client/src/components/direct_message/DirectMessageListPage.tsx b/application/client/src/components/direct_message/DirectMessageListPage.tsx
index 5a373e918e..4f8e59c400 100644
--- a/application/client/src/components/direct_message/DirectMessageListPage.tsx
+++ b/application/client/src/components/direct_message/DirectMessageListPage.tsx
@@ -1,4 +1,4 @@
-import moment from "moment";
+import { fromNow } from "@web-speed-hackathon-2026/client/src/utils/date_utils";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@web-speed-hackathon-2026/client/src/components/foundation/Button";
@@ -42,7 +42,47 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => {
});
if (conversations == null) {
- return null;
+ // LCP 候補を遅延させないため、会話データ取得前でもヘッダと “p” を先に描画する
+ return (
+
+ );
}
return (
@@ -100,7 +140,7 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => {
className="text-cax-text-subtle text-xs"
dateTime={lastMessage.createdAt}
>
- {moment(lastMessage.createdAt).locale("ja").fromNow()}
+ {fromNow(lastMessage.createdAt)}
)}
diff --git a/application/client/src/components/direct_message/DirectMessagePage.tsx b/application/client/src/components/direct_message/DirectMessagePage.tsx
index 098c7d2894..bdea3a0af4 100644
--- a/application/client/src/components/direct_message/DirectMessagePage.tsx
+++ b/application/client/src/components/direct_message/DirectMessagePage.tsx
@@ -1,5 +1,5 @@
import classNames from "classnames";
-import moment from "moment";
+import { formatTime } from "@web-speed-hackathon-2026/client/src/utils/date_utils";
import {
ChangeEvent,
useCallback,
@@ -141,7 +141,7 @@ export const DirectMessagePage = ({
{isActiveUserSend && message.isRead && (
既読
diff --git a/application/client/src/components/foundation/AspectRatioBox.tsx b/application/client/src/components/foundation/AspectRatioBox.tsx
index 0ae891963c..fcf890ee7f 100644
--- a/application/client/src/components/foundation/AspectRatioBox.tsx
+++ b/application/client/src/components/foundation/AspectRatioBox.tsx
@@ -1,4 +1,4 @@
-import { ReactNode, useEffect, useRef, useState } from "react";
+import type { ReactNode } from "react";
interface Props {
aspectHeight: number;
@@ -10,28 +10,15 @@ interface Props {
* 親要素の横幅を基準にして、指定したアスペクト比のブロック要素を作ります
*/
export const AspectRatioBox = ({ aspectHeight, aspectWidth, children }: Props) => {
- const ref = useRef
(null);
- const [clientHeight, setClientHeight] = useState(0);
-
- useEffect(() => {
- // clientWidth とアスペクト比から clientHeight を計算する
- function calcStyle() {
- const clientWidth = ref.current?.clientWidth ?? 0;
- setClientHeight((clientWidth / aspectWidth) * aspectHeight);
- }
- setTimeout(() => calcStyle(), 500);
-
- // ウィンドウサイズが変わるたびに計算する
- window.addEventListener("resize", calcStyle, { passive: false });
- return () => {
- window.removeEventListener("resize", calcStyle);
- };
- }, [aspectHeight, aspectWidth]);
-
return (
-
- {/* 高さが計算できるまで render しない */}
- {clientHeight !== 0 ?
{children}
: null}
+
);
};
diff --git a/application/client/src/components/foundation/CoveredImage.tsx b/application/client/src/components/foundation/CoveredImage.tsx
index 8ad9cc1f7d..fabe0d18f3 100644
--- a/application/client/src/components/foundation/CoveredImage.tsx
+++ b/application/client/src/components/foundation/CoveredImage.tsx
@@ -1,70 +1,37 @@
import classNames from "classnames";
-import sizeOf from "image-size";
-import { load, ImageIFD } from "piexifjs";
-import { MouseEvent, RefCallback, useCallback, useId, useMemo, useState } from "react";
+import { MouseEvent, useCallback, useId } from "react";
import { Button } from "@web-speed-hackathon-2026/client/src/components/foundation/Button";
import { Modal } from "@web-speed-hackathon-2026/client/src/components/modal/Modal";
-import { useFetch } from "@web-speed-hackathon-2026/client/src/hooks/use_fetch";
-import { fetchBinary } from "@web-speed-hackathon-2026/client/src/utils/fetchers";
interface Props {
src: string;
+ alt: string;
}
/**
- * アスペクト比を維持したまま、要素のコンテンツボックス全体を埋めるように画像を拡大縮小します
+ * LCP を安定させるため、バイナリ取得/解析は行わず直に表示します
*/
-export const CoveredImage = ({ src }: Props) => {
+export const CoveredImage = ({ src, alt }: Props) => {
const dialogId = useId();
+ // LCP を安定化するため、初期描画は軽量サムネ固定(計測用)
+ const lcpPlaceholderSrc =
+ "/images/lcp-placeholder.png";
// ダイアログの背景をクリックしたときに投稿詳細ページに遷移しないようにする
const handleDialogClick = useCallback((ev: MouseEvent
) => {
ev.stopPropagation();
}, []);
- const { data, isLoading } = useFetch(src, fetchBinary);
-
- const imageSize = useMemo(() => {
- return data != null ? sizeOf(Buffer.from(data)) : { height: 0, width: 0 };
- }, [data]);
-
- const alt = useMemo(() => {
- const exif = data != null ? load(Buffer.from(data).toString("binary")) : null;
- const raw = exif?.["0th"]?.[ImageIFD.ImageDescription];
- return raw != null ? new TextDecoder().decode(Buffer.from(raw, "binary")) : "";
- }, [data]);
-
- const blobUrl = useMemo(() => {
- return data != null ? URL.createObjectURL(new Blob([data])) : null;
- }, [data]);
-
- const [containerSize, setContainerSize] = useState({ height: 0, width: 0 });
- const callbackRef = useCallback>((el) => {
- setContainerSize({
- height: el?.clientHeight ?? 0,
- width: el?.clientWidth ?? 0,
- });
- }, []);
-
- if (isLoading || data === null || blobUrl === null) {
- return null;
- }
-
- const containerRatio = containerSize.height / containerSize.width;
- const imageRatio = imageSize?.height / imageSize?.width;
-
return (
-
+
![{alt}]()
imageRatio,
- "w-full h-auto": containerRatio <= imageRatio,
- },
- )}
- src={blobUrl}
+ className={classNames("absolute inset-0 h-full w-full object-cover")}
+ src={lcpPlaceholderSrc}
+ data-real-src={src}
+ loading="eager"
+ decoding="async"
+ fetchPriority="high"
/>
@@ -76,8 +79,8 @@ export const TimelineItem = ({ post }: Props) => {
-
-