diff --git a/website/i18n/en/code.json b/website/i18n/en/code.json
index 2f6bc3878..e3e336f4c 100644
--- a/website/i18n/en/code.json
+++ b/website/i18n/en/code.json
@@ -8,6 +8,12 @@
"github": {
"message": "GitHub"
},
+ "homepage.playground.hint": {
+ "message": "Every @ExcelProperty becomes a column header."
+ },
+ "homepage.playground.sheet.caption": {
+ "message": "The spreadsheet Fesod writes from the class beside it"
+ },
"homepage.feature.quick.title": {
"message": "High-performance"
},
diff --git a/website/i18n/zh-cn/code.json b/website/i18n/zh-cn/code.json
index e65d040dc..56b2866b6 100644
--- a/website/i18n/zh-cn/code.json
+++ b/website/i18n/zh-cn/code.json
@@ -8,6 +8,12 @@
"github": {
"message": "GitHub"
},
+ "homepage.playground.hint": {
+ "message": "每个 @ExcelProperty 对应一个列标题。"
+ },
+ "homepage.playground.sheet.caption": {
+ "message": "Fesod 依据左侧类写出的电子表格"
+ },
"homepage.feature.quick.title": {
"message": "快速"
},
diff --git a/website/src/components/HeroPlayground/index.js b/website/src/components/HeroPlayground/index.js
new file mode 100644
index 000000000..9a419aa22
--- /dev/null
+++ b/website/src/components/HeroPlayground/index.js
@@ -0,0 +1,213 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {useState} from 'react';
+import clsx from 'clsx';
+import Translate from '@docusaurus/Translate';
+import styles from './styles.module.css';
+
+const FIELDS = [
+ {
+ key: 'name',
+ type: 'String',
+ header: 'Name',
+ align: 'left',
+ values: ['Ada', 'Lin', 'Sun', 'Rui'],
+ },
+ {
+ key: 'date',
+ type: 'Date',
+ header: 'Date',
+ align: 'right',
+ values: ['2026-07-28', '2026-07-28', '2026-07-29', '2026-07-30'],
+ },
+ {
+ key: 'amount',
+ type: 'Double',
+ header: 'Amount',
+ align: 'right',
+ values: ['0.56', '1.20', '4.80', '2.35'],
+ },
+ /* Annotated @ExcelIgnore, so it never reaches the sheet. */
+ {
+ key: 'note',
+ type: 'String',
+ ignored: true,
+ },
+];
+
+const COLUMNS = FIELDS.filter((field) => !field.ignored);
+const ROW_COUNT = 4;
+
+/* Empty rows below the data, so the pane reads as a sheet rather than stopping short. */
+const FILLER_ROWS = 1;
+
+function columnLetter(index) {
+ return String.fromCharCode(65 + index);
+}
+
+export default function HeroPlayground() {
+ const [activeKey, setActiveKey] = useState(null);
+
+ const link = (key) => ({
+ onMouseEnter: () => setActiveKey(key),
+ onMouseLeave: () => setActiveKey(null),
+ });
+
+ return (
+
+
+
+
DemoData.java
+
+
+ class
+ DemoData {'{'}
+ {FIELDS.map((field) => (
+
+
+ {field.ignored ? (
+ @ExcelIgnore
+ ) : (
+ <>
+
+ @ExcelProperty
+
+ {'("'}
+
+ {field.header}
+
+ {'")'}
+ >
+ )}
+
+
+ private
+ {field.type} {field.key};
+
+
+ ))}
+ {'}'}
+
+
+
+
+
+
demo.xlsx
+
+
+
+
+ The spreadsheet Fesod writes from the class beside it
+
+
+
+
+ |
+ {COLUMNS.map((field, index) => (
+
+ {columnLetter(index)}
+ |
+ ))}
+
+
+ | 1 |
+ {COLUMNS.map((field) => (
+
+ {field.header}
+ |
+ ))}
+
+
+
+ {Array.from({length: ROW_COUNT}, (_, row) => (
+
+ | {row + 2} |
+ {COLUMNS.map((field) => (
+
+ {field.values[row]}
+ |
+ ))}
+
+ ))}
+ {Array.from({length: FILLER_ROWS}, (_, row) => (
+
+ |
+ {ROW_COUNT + row + 2}
+ |
+ {COLUMNS.map((field) => (
+
+
+ |
+ ))}
+
+ ))}
+
+
+
+
Template
+
+
+
+
+
+ FesodSheet.write("demo.xlsx", DemoData.class).sheet("Template").doWrite(data());
+
+
+
+
+
+ Every @ExcelProperty becomes a column header.
+
+
+
+ );
+}
diff --git a/website/src/components/HeroPlayground/styles.module.css b/website/src/components/HeroPlayground/styles.module.css
new file mode 100644
index 000000000..61372a6a3
--- /dev/null
+++ b/website/src/components/HeroPlayground/styles.module.css
@@ -0,0 +1,292 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/*
+ * The only chromatic value below is the Apache Fesod brand green, #14824b
+ * (R20 G130 B75). Every other value is white or black at some alpha, so the
+ * panel shades itself against the hero instead of introducing a second palette.
+ */
+.playground {
+ --pg-brand: #14824b;
+ --pg-brand-wash: rgba(20, 130, 75, 0.12);
+ --pg-brand-wash-strong: rgba(20, 130, 75, 0.18);
+ --pg-brand-tint: rgba(20, 130, 75, 0.45);
+ --pg-edge: rgba(255, 255, 255, 0.22);
+ --pg-rule: rgba(0, 0, 0, 0.13);
+ --pg-ink: rgba(0, 0, 0, 0.88);
+ --pg-ink-muted: rgba(0, 0, 0, 0.5);
+ --pg-gutter: rgba(0, 0, 0, 0.04);
+
+ /* Infima stripes every second table row; a spreadsheet's cells are uniform,
+ and the stripes fight the column highlight. */
+ --ifm-table-stripe-background: transparent;
+
+ font-family: var(--ifm-font-family-monospace, monospace);
+ text-align: left;
+}
+
+.panes {
+ display: flex;
+ align-items: stretch;
+ border: 1px solid var(--pg-edge);
+ border-bottom: none;
+ border-radius: 12px 12px 0 0;
+ overflow: hidden;
+ box-shadow: 0 24px 48px rgba(0, 0, 0, 0.32);
+}
+
+/* Near-black rather than a translucent green, so the code stops competing
+ with the hero it sits on. */
+.codePane {
+ flex: 0 0 46%;
+ min-width: 0;
+ background: rgba(0, 0, 0, 0.72);
+}
+
+.sheetPane {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ min-width: 0;
+ background: #ffffff;
+}
+
+.paneLabel {
+ padding: 9px 14px;
+ font-size: 0.75rem;
+ letter-spacing: 0.02em;
+}
+
+.codePane .paneLabel {
+ color: rgba(255, 255, 255, 0.6);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.14);
+}
+
+.sheetPane .paneLabel {
+ color: var(--pg-ink-muted);
+ border-bottom: 1px solid var(--pg-rule);
+}
+
+/* Code pane ------------------------------------------------------------- */
+
+.code {
+ margin: 0;
+ padding: 14px 16px 18px;
+ background: none;
+ border-radius: 0;
+ font-size: 0.875rem;
+ line-height: 1.7;
+ color: rgba(255, 255, 255, 0.92);
+ white-space: pre;
+ overflow-x: auto;
+}
+
+.field {
+ display: block;
+ margin-top: 0.5em;
+ padding-left: 2.2ch;
+ border-radius: 4px;
+ transition: background-color 120ms ease;
+}
+
+.fieldActive {
+ background: rgba(20, 130, 75, 0.34);
+}
+
+.annotationLine,
+.declaration {
+ display: block;
+}
+
+.annotation {
+ color: #ffffff;
+}
+
+.punctuation {
+ color: rgba(255, 255, 255, 0.58);
+}
+
+.declaration {
+ color: rgba(255, 255, 255, 0.72);
+}
+
+/*
+ * The header string carries the brand tint that its column header carries in
+ * the sheet, so the eye pairs the two without anything needing to be clicked.
+ * The negative margin cancels the padding, so the tint bleeds past the glyphs
+ * without pushing the quotes out and implying spaces inside the string.
+ */
+.headerLiteral {
+ padding: 0 2px;
+ margin: 0 -2px;
+ color: #ffffff;
+ background: var(--pg-brand-tint);
+ border-radius: 2px;
+ transition: background-color 120ms ease;
+}
+
+.fieldActive .headerLiteral {
+ background: var(--pg-brand);
+}
+
+/* Sheet pane ------------------------------------------------------------ */
+
+.sheetScroll {
+ overflow-x: auto;
+}
+
+/* Infima sets `table { display: block }` for scrolling; .sheetScroll does that
+ job here, and the cells need real table layout to fill the pane. */
+.sheet {
+ display: table;
+ width: 100%;
+ margin: 0;
+ font-size: 0.875rem;
+ color: var(--pg-ink);
+ border-collapse: collapse;
+ table-layout: auto;
+}
+
+.sheet td,
+.sheet th {
+ border: 1px solid var(--pg-rule);
+}
+
+.corner,
+.columnLetter,
+.rowNumber {
+ padding: 4px 6px;
+ font-size: 0.6875rem;
+ font-weight: 400;
+ color: var(--pg-ink-muted);
+ text-align: center;
+ background: var(--pg-gutter);
+}
+
+.rowNumber {
+ width: 2rem;
+}
+
+.columnLetter {
+ transition: background-color 120ms ease, color 120ms ease;
+}
+
+.columnLetterActive {
+ color: #ffffff;
+ background: var(--pg-brand);
+}
+
+.headerCell {
+ padding: 10px 12px;
+ font-weight: 600;
+ white-space: nowrap;
+ background: var(--pg-brand-wash);
+ border-bottom: 2px solid var(--pg-brand);
+}
+
+.cell {
+ padding: 9px 12px;
+ white-space: nowrap;
+ transition: background-color 120ms ease;
+}
+
+.cellActive {
+ background: var(--pg-brand-wash-strong);
+}
+
+.sheetTab {
+ margin-top: auto;
+ padding: 9px 14px;
+ font-size: 0.75rem;
+ color: var(--pg-brand);
+ border-top: 1px solid var(--pg-rule);
+}
+
+.sheetTab::before {
+ content: '';
+ display: inline-block;
+ width: 7px;
+ height: 7px;
+ margin-right: 7px;
+ vertical-align: baseline;
+ background: var(--pg-brand);
+ border-radius: 1px;
+}
+
+/* Status bar and hint --------------------------------------------------- */
+
+.statusBar {
+ padding: 11px 16px;
+ font-size: 0.8125rem;
+ color: rgba(255, 255, 255, 0.82);
+ background: rgba(0, 0, 0, 0.8);
+ border: 1px solid var(--pg-edge);
+ border-radius: 0 0 12px 12px;
+ overflow-x: auto;
+}
+
+.statusBar code {
+ padding: 0;
+ color: inherit;
+ white-space: pre;
+ background: none;
+ border: none;
+}
+
+.hint {
+ margin: 0.9rem 0 0;
+ font-size: 0.9375rem;
+ color: rgba(255, 255, 255, 0.82);
+}
+
+.srOnly {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+@media screen and (max-width: 996px) {
+ .playground {
+ max-width: 640px;
+ margin: 0 auto;
+ }
+
+ .panes {
+ flex-direction: column;
+ }
+
+ .codePane {
+ flex: 1 1 auto;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .field,
+ .columnLetter,
+ .cell,
+ .headerLiteral {
+ transition: none;
+ }
+}
diff --git a/website/src/css/custom.css b/website/src/css/custom.css
index 1285023a3..4167f5e34 100644
--- a/website/src/css/custom.css
+++ b/website/src/css/custom.css
@@ -52,7 +52,7 @@
}
.hero {
- background: radial-gradient(circle, #00b95f 20%, #69CD37 50%, #14824b 100%);
+ background: linear-gradient(135deg, rgba(0, 0, 0, 0.45) 0%, rgba(0, 0, 0, 0) 100%) #14824b;
}
.header-github-link::before {
diff --git a/website/src/pages/index.js b/website/src/pages/index.js
index cf1104f89..4eb86063e 100644
--- a/website/src/pages/index.js
+++ b/website/src/pages/index.js
@@ -22,39 +22,75 @@ import Link from '@docusaurus/Link';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import Layout from '@theme/Layout';
import HomepageFeatures from '@site/src/components/HomepageFeatures';
+import HeroPlayground from '@site/src/components/HeroPlayground';
import Translate from '@docusaurus/Translate';
import Heading from '@theme/Heading';
import styles from './index.module.css';
+const GRID_SIZE = 48;
+const COLUMN_COUNT = 50;
+const ROW_COUNT = 14;
+
+function columnLetter(index) {
+ let n = index + 1;
+ let label = '';
+ while (n > 0) {
+ const remainder = (n - 1) % 26;
+ label = String.fromCharCode(65 + remainder) + label;
+ n = Math.floor((n - 1) / 26);
+ }
+ return label;
+}
+
function HomepageHeader() {
const {siteConfig} = useDocusaurusContext();
return (
-
-
- {siteConfig.title}
-
-
- site.description
-
-
-
-
quickstart
-
+
+
+ {Array.from({length: COLUMN_COUNT}, (_, i) => (
+
+ {columnLetter(i)}
+
+ ))}
+
+
+ {Array.from({length: ROW_COUNT}, (_, i) => (
+
+ {i + 1}
+
+ ))}
+
+
+
+
+ {siteConfig.title}
+
+
+ site.description
+
+
+
+
quickstart
+
-
-

-
github
-
+
+

+
github
+
+
+
+
+
diff --git a/website/src/pages/index.module.css b/website/src/pages/index.module.css
index dead0f1c6..d51776de2 100644
--- a/website/src/pages/index.module.css
+++ b/website/src/pages/index.module.css
@@ -58,3 +58,95 @@
width: 20px;
height: 20px;
}
+
+.gridOverlay {
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ pointer-events: none;
+ background-image:
+ linear-gradient(rgba(255, 255, 255, 0.07) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(255, 255, 255, 0.07) 1px, transparent 1px);
+ background-size: 48px 48px;
+ -webkit-mask-image: linear-gradient(to bottom, transparent, black 20%, black 80%, transparent);
+ mask-image: linear-gradient(to bottom, transparent, black 20%, black 80%, transparent);
+}
+
+.colLabels,
+.rowLabels {
+ position: absolute;
+ z-index: 0;
+ pointer-events: none;
+ font-family: var(--ifm-font-family-monospace, monospace);
+ font-size: 0.7rem;
+ color: rgba(255, 255, 255, 0.22);
+}
+
+.colLabels {
+ top: 8px;
+ left: 0;
+ height: 16px;
+}
+
+.colLabels span {
+ position: absolute;
+ top: 0;
+ text-align: center;
+}
+
+.rowLabels {
+ top: 0;
+ left: 8px;
+ width: 16px;
+}
+
+.rowLabels span {
+ position: absolute;
+ left: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.heroContent {
+ position: relative;
+ z-index: 2;
+}
+
+.heroDemo {
+ margin-top: 2.5rem;
+}
+
+@media screen and (min-width: 997px) {
+ .heroBanner {
+ padding: 4.5rem 0;
+ }
+
+ /* The demo is the densest thing on the page, so the hero opts out of the
+ 1140px content width the rest of the page uses and gives it room. */
+ .heroContent {
+ --ifm-container-width: 1280px;
+ --ifm-container-width-xl: 1280px;
+
+ display: grid;
+ grid-template-columns: minmax(0, 4.5fr) minmax(0, 7.5fr);
+ gap: 3rem;
+ align-items: center;
+ text-align: left;
+ }
+
+ .heroCopy .buttons {
+ justify-content: flex-start;
+ }
+
+ .heroDemo {
+ margin-top: 0;
+ }
+}
+
+@media screen and (max-width: 996px) {
+ .colLabels,
+ .rowLabels {
+ display: none;
+ }
+}