Compare commits
5 Commits
PowerSaveP
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3da410770a | |||
| 61f4b5acd6 | |||
| 190ac299e3 | |||
| f59726be8d | |||
| a51e498928 |
205
index.js
205
index.js
@@ -52,8 +52,10 @@ const wsMessageRateLimiter = new RateLimiterMemory({
|
|||||||
duration: 1,
|
duration: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = 3000;
|
const port = 3000;
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
app.use(json());
|
app.use(json());
|
||||||
|
|
||||||
// Rate limiting middleware for HTTP requests
|
// Rate limiting middleware for HTTP requests
|
||||||
@@ -100,6 +102,11 @@ let agenda;
|
|||||||
// Add fcm_token column for push notification delivery
|
// Add fcm_token column for push notification delivery
|
||||||
await pool.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS fcm_token TEXT");
|
await pool.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS fcm_token TEXT");
|
||||||
|
|
||||||
|
// Add timezone support
|
||||||
|
await pool.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS timezone TEXT DEFAULT 'America/Chicago'");
|
||||||
|
await pool.query("ALTER TABLE devices ADD COLUMN IF NOT EXISTS timezone TEXT");
|
||||||
|
await pool.query("ALTER TABLE groups ADD COLUMN IF NOT EXISTS timezone TEXT");
|
||||||
|
|
||||||
// Initialise Firebase Admin SDK for push notifications
|
// Initialise Firebase Admin SDK for push notifications
|
||||||
if (process.env.FIREBASE_SERVICE_ACCOUNT_JSON) {
|
if (process.env.FIREBASE_SERVICE_ACCOUNT_JSON) {
|
||||||
admin.initializeApp({
|
admin.initializeApp({
|
||||||
@@ -279,10 +286,13 @@ io.on('connection', async (socket) => {
|
|||||||
|
|
||||||
// Update calibration status in database based on device's actual state
|
// Update calibration status in database based on device's actual state
|
||||||
if (data.port && typeof data.calibrated === 'boolean') {
|
if (data.port && typeof data.calibrated === 'boolean') {
|
||||||
const result = await pool.query(
|
// When the device declares itself uncalibrated, also clear await_calib so
|
||||||
"update peripherals set calibrated=$1 where device_id=$2 and peripheral_number=$3 returning id, user_id",
|
// any stale pending-calibration state is gone. The device is already connected
|
||||||
[data.calibrated, rows[0].device_id, data.port]
|
// via socket, so the next calib_start from the agenda job will land correctly.
|
||||||
);
|
const updateQuery = data.calibrated
|
||||||
|
? "update peripherals set calibrated=TRUE where device_id=$1 and peripheral_number=$2 returning id, user_id"
|
||||||
|
: "update peripherals set calibrated=FALSE, await_calib=FALSE where device_id=$1 and peripheral_number=$2 returning id, user_id";
|
||||||
|
const result = await pool.query(updateQuery, [rows[0].device_id, data.port]);
|
||||||
console.log(`Updated port ${data.port} calibrated status to ${data.calibrated}`);
|
console.log(`Updated port ${data.port} calibrated status to ${data.calibrated}`);
|
||||||
|
|
||||||
// Notify user app of calibration status change
|
// Notify user app of calibration status change
|
||||||
@@ -638,9 +648,6 @@ async function authenticateToken(req, res, next) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get('/', (req, res) => {
|
|
||||||
res.send('Hello World!');
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/login', async (req, res) => {
|
app.post('/login', async (req, res) => {
|
||||||
const { email, password } = req.body;
|
const { email, password } = req.body;
|
||||||
@@ -892,7 +899,7 @@ app.post('/logout', authenticateToken, async (req, res) => {
|
|||||||
app.get('/account_info', authenticateToken, async (req, res) => {
|
app.get('/account_info', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const {rows} = await pool.query(
|
const {rows} = await pool.query(
|
||||||
'SELECT name, email, created_at FROM users WHERE id = $1',
|
'SELECT name, email, created_at, COALESCE(timezone, \'America/Chicago\') AS timezone FROM users WHERE id = $1',
|
||||||
[req.user]
|
[req.user]
|
||||||
);
|
);
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
@@ -901,7 +908,8 @@ app.get('/account_info', authenticateToken, async (req, res) => {
|
|||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
name: rows[0].name,
|
name: rows[0].name,
|
||||||
email: rows[0].email,
|
email: rows[0].email,
|
||||||
created_at: rows[0].created_at
|
created_at: rows[0].created_at,
|
||||||
|
timezone: rows[0].timezone
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -1358,10 +1366,10 @@ app.get('/device_name', authenticateToken, async (req, res) => {
|
|||||||
console.log("deviceName");
|
console.log("deviceName");
|
||||||
try {
|
try {
|
||||||
const {deviceId} = req.query;
|
const {deviceId} = req.query;
|
||||||
const {rows} = await pool.query('select device_name, max_ports, battery_soc from devices where id=$1 and user_id=$2',
|
const {rows} = await pool.query('select device_name, max_ports, battery_soc, timezone from devices where id=$1 and user_id=$2',
|
||||||
[deviceId, req.user]);
|
[deviceId, req.user]);
|
||||||
if (rows.length != 1) return res.sendStatus(404);
|
if (rows.length != 1) return res.sendStatus(404);
|
||||||
res.status(200).json({device_name: rows[0].device_name, max_ports: rows[0].max_ports, battery_soc: rows[0].battery_soc});
|
res.status(200).json({device_name: rows[0].device_name, max_ports: rows[0].max_ports, battery_soc: rows[0].battery_soc, timezone: rows[0].timezone});
|
||||||
} catch {
|
} catch {
|
||||||
res.sendStatus(500);
|
res.sendStatus(500);
|
||||||
}
|
}
|
||||||
@@ -1387,13 +1395,14 @@ app.post('/add_device', authenticateToken, async (req, res) => {
|
|||||||
console.log("add device request");
|
console.log("add device request");
|
||||||
console.log(req.user);
|
console.log(req.user);
|
||||||
console.log(req.peripheral);
|
console.log(req.peripheral);
|
||||||
const {deviceName, maxPorts} = req.body;
|
const {deviceName, maxPorts, timezone} = req.body;
|
||||||
console.log(deviceName);
|
console.log(deviceName);
|
||||||
const ports = maxPorts || 4; // Default to 4 for multi-port devices
|
const ports = maxPorts || 4; // Default to 4 for multi-port devices
|
||||||
const {rows} = await pool.query("insert into devices (user_id, device_name, max_ports) values ($1, $2, $3) returning id",
|
const {rows} = await pool.query("insert into devices (user_id, device_name, max_ports, timezone) values ($1, $2, $3, $4) returning id",
|
||||||
[req.user, deviceName, ports]
|
[req.user, deviceName, ports, timezone || null]
|
||||||
); // finish token return based on device ID.
|
); // finish token return based on device ID.
|
||||||
const deviceInitToken = await createTempPeriphToken(rows[0].id);
|
const deviceInitToken = await createTempPeriphToken(rows[0].id);
|
||||||
|
console.log("complete");
|
||||||
res.status(201).json({token: deviceInitToken});
|
res.status(201).json({token: deviceInitToken});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
@@ -1478,14 +1487,20 @@ app.post('/position', authenticateToken, async (req, res) => {
|
|||||||
console.log("devicepos");
|
console.log("devicepos");
|
||||||
try {
|
try {
|
||||||
const {port, pos} = req.body;
|
const {port, pos} = req.body;
|
||||||
const {rows} = await pool.query("update peripherals set last_pos=$1 where device_id=$2 and peripheral_number=$3 returning await_calib",
|
const {rows} = await pool.query("update peripherals set last_pos=$1 where device_id=$2 and peripheral_number=$3 returning await_calib, id, user_id",
|
||||||
[pos, req.peripheral, port]);
|
[pos, req.peripheral, port]);
|
||||||
|
|
||||||
if (rows.length != 1) {
|
if (rows.length != 1) {
|
||||||
return res.sendStatus(404);
|
return res.sendStatus(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(201).json(rows[0]);
|
res.status(201).json({await_calib: rows[0].await_calib});
|
||||||
|
|
||||||
|
// Notify user app of device-reported position
|
||||||
|
const {rows: userRows} = await pool.query("select socket from user_tokens where user_id=$1 and connected=TRUE", [rows[0].user_id]);
|
||||||
|
if (userRows.length === 1 && userRows[0]) {
|
||||||
|
io.to(userRows[0].socket).emit("device_pos_report", {periphID: rows[0].id, pos});
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
res.status(500).json({error: "server error"});
|
res.status(500).json({error: "server error"});
|
||||||
}
|
}
|
||||||
@@ -1815,6 +1830,28 @@ function createCronExpression(time, daysOfWeek) {
|
|||||||
return `${time.minute} ${time.hour} * * ${cronDays}`;
|
return `${time.minute} ${time.hour} * * ${cronDays}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Look up the effective timezone for a device's schedules (device → user fallback)
|
||||||
|
async function getScheduleTimezone(deviceId, userId) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT COALESCE(d.timezone, u.timezone, 'America/Chicago') AS tz
|
||||||
|
FROM devices d JOIN users u ON d.user_id = u.id
|
||||||
|
WHERE d.id = $1 AND d.user_id = $2`,
|
||||||
|
[deviceId, userId]
|
||||||
|
);
|
||||||
|
return rows.length > 0 ? rows[0].tz : 'America/Chicago';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the effective timezone for a group's schedules (group → user fallback)
|
||||||
|
async function getGroupScheduleTimezone(groupId, userId) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT COALESCE(g.timezone, u.timezone, 'America/Chicago') AS tz
|
||||||
|
FROM groups g JOIN users u ON g.user_id = u.id
|
||||||
|
WHERE g.id = $1 AND g.user_id = $2`,
|
||||||
|
[groupId, userId]
|
||||||
|
);
|
||||||
|
return rows.length > 0 ? rows[0].tz : 'America/Chicago';
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function to find and verify a schedule job belongs to the user
|
// Helper function to find and verify a schedule job belongs to the user
|
||||||
async function findUserScheduleJob(jobId, userId) {
|
async function findUserScheduleJob(jobId, userId) {
|
||||||
const jobs = await agenda.jobs({
|
const jobs = await agenda.jobs({
|
||||||
@@ -1862,16 +1899,18 @@ app.post('/add_schedule', authenticateToken, async (req, res) => {
|
|||||||
const changedPosList = [{periphNum: periphNum, periphID: periphId, pos: newPos}];
|
const changedPosList = [{periphNum: periphNum, periphID: periphId, pos: newPos}];
|
||||||
|
|
||||||
// Schedule the recurring job
|
// Schedule the recurring job
|
||||||
|
const tz = await getScheduleTimezone(deviceId, req.user);
|
||||||
const job = await agenda.create('posChangeScheduled', {
|
const job = await agenda.create('posChangeScheduled', {
|
||||||
deviceID: deviceId,
|
deviceID: deviceId,
|
||||||
changedPosList: changedPosList,
|
changedPosList: changedPosList,
|
||||||
userID: req.user
|
userID: req.user
|
||||||
});
|
});
|
||||||
|
|
||||||
job.repeatEvery(cronExpression, {
|
job.repeatEvery(cronExpression, {
|
||||||
|
timezone: tz,
|
||||||
skipImmediate: true
|
skipImmediate: true
|
||||||
});
|
});
|
||||||
|
|
||||||
await job.save();
|
await job.save();
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
@@ -1949,13 +1988,15 @@ app.post('/update_schedule', authenticateToken, async (req, res) => {
|
|||||||
|
|
||||||
console.log("Creating new job with cron:", cronExpression);
|
console.log("Creating new job with cron:", cronExpression);
|
||||||
// Create new job with updated schedule
|
// Create new job with updated schedule
|
||||||
|
const tz = await getScheduleTimezone(deviceId, req.user);
|
||||||
const job = await agenda.create('posChangeScheduled', {
|
const job = await agenda.create('posChangeScheduled', {
|
||||||
deviceID: deviceId,
|
deviceID: deviceId,
|
||||||
changedPosList: changedPosList,
|
changedPosList: changedPosList,
|
||||||
userID: req.user
|
userID: req.user
|
||||||
});
|
});
|
||||||
|
|
||||||
job.repeatEvery(cronExpression, {
|
job.repeatEvery(cronExpression, {
|
||||||
|
timezone: tz,
|
||||||
skipImmediate: true
|
skipImmediate: true
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2058,7 +2099,7 @@ app.get('/group_list', authenticateToken, async (req, res) => {
|
|||||||
app.post('/add_group', authenticateToken, async (req, res) => {
|
app.post('/add_group', authenticateToken, async (req, res) => {
|
||||||
console.log("add_group request for user:", req.user);
|
console.log("add_group request for user:", req.user);
|
||||||
try {
|
try {
|
||||||
const { name, peripheral_ids } = req.body;
|
const { name, peripheral_ids, timezone } = req.body;
|
||||||
|
|
||||||
// Validate input
|
// Validate input
|
||||||
if (!name || !name.trim()) {
|
if (!name || !name.trim()) {
|
||||||
@@ -2104,11 +2145,11 @@ app.post('/add_group', authenticateToken, async (req, res) => {
|
|||||||
|
|
||||||
// Insert into groups table
|
// Insert into groups table
|
||||||
const insertGroupQuery = `
|
const insertGroupQuery = `
|
||||||
INSERT INTO groups (user_id, name)
|
INSERT INTO groups (user_id, name, timezone)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2, $3)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
const { rows: groupRows } = await pool.query(insertGroupQuery, [req.user, name.trim()]);
|
const { rows: groupRows } = await pool.query(insertGroupQuery, [req.user, name.trim(), timezone || null]);
|
||||||
const groupId = groupRows[0].id;
|
const groupId = groupRows[0].id;
|
||||||
|
|
||||||
// Insert into group_peripherals table
|
// Insert into group_peripherals table
|
||||||
@@ -2291,6 +2332,7 @@ app.post('/add_group_schedule', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create the job
|
// Create the job
|
||||||
|
const tz = await getGroupScheduleTimezone(groupId, req.user);
|
||||||
const job = await agenda.create('groupPosChangeScheduled', {
|
const job = await agenda.create('groupPosChangeScheduled', {
|
||||||
groupID: groupId,
|
groupID: groupId,
|
||||||
newPos,
|
newPos,
|
||||||
@@ -2298,7 +2340,7 @@ app.post('/add_group_schedule', authenticateToken, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
job.repeatEvery(cronExpression, {
|
job.repeatEvery(cronExpression, {
|
||||||
timezone: 'America/New_York',
|
timezone: tz,
|
||||||
skipImmediate: true
|
skipImmediate: true
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2369,9 +2411,10 @@ app.post('/update_group_schedule', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update job
|
// Update job
|
||||||
|
const tz = await getGroupScheduleTimezone(groupId, req.user);
|
||||||
job.attrs.data.newPos = newPos;
|
job.attrs.data.newPos = newPos;
|
||||||
job.repeatEvery(cronExpression, {
|
job.repeatEvery(cronExpression, {
|
||||||
timezone: 'America/New_York',
|
timezone: tz,
|
||||||
skipImmediate: true
|
skipImmediate: true
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2488,7 +2531,9 @@ app.post('/group_schedule_list', authenticateToken, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const scheduledUpdates = jobs.map(job => {
|
const scheduledUpdates = jobs.map(job => {
|
||||||
const interval = cronParser.parseExpression(job.attrs.repeatInterval);
|
const interval = cronParser.parseExpression(job.attrs.repeatInterval, {
|
||||||
|
tz: job.attrs.repeatTimezone || undefined
|
||||||
|
});
|
||||||
const schedule = {
|
const schedule = {
|
||||||
minutes: interval.fields.minute,
|
minutes: interval.fields.minute,
|
||||||
hours: interval.fields.hour,
|
hours: interval.fields.hour,
|
||||||
@@ -2509,3 +2554,111 @@ app.post('/group_schedule_list', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/user_timezone', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
"SELECT COALESCE(timezone, 'America/Chicago') AS timezone FROM users WHERE id=$1",
|
||||||
|
[req.user]
|
||||||
|
);
|
||||||
|
res.status(200).json({ timezone: rows[0]?.timezone ?? 'America/Chicago' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user timezone:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/update_user_timezone', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { timezone } = req.body;
|
||||||
|
if (!timezone || typeof timezone !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'timezone is required' });
|
||||||
|
}
|
||||||
|
// Validate it's a recognisable IANA name by running it through cron-parser
|
||||||
|
try {
|
||||||
|
cronParser.parseExpression('0 0 * * 0', { tz: timezone });
|
||||||
|
} catch {
|
||||||
|
return res.status(400).json({ error: 'Invalid IANA timezone' });
|
||||||
|
}
|
||||||
|
await pool.query("UPDATE users SET timezone=$1 WHERE id=$2", [timezone, req.user]);
|
||||||
|
res.status(200).json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating user timezone:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/update_device_timezone', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { deviceId, timezone } = req.body;
|
||||||
|
if (!deviceId || !timezone) return res.status(400).json({ error: 'Missing required fields' });
|
||||||
|
try {
|
||||||
|
cronParser.parseExpression('0 0 * * 0', { tz: timezone });
|
||||||
|
} catch {
|
||||||
|
return res.status(400).json({ error: 'Invalid IANA timezone' });
|
||||||
|
}
|
||||||
|
const result = await pool.query(
|
||||||
|
'UPDATE devices SET timezone=$1 WHERE id=$2 AND user_id=$3',
|
||||||
|
[timezone, deviceId, req.user]
|
||||||
|
);
|
||||||
|
if (result.rowCount === 0) return res.status(404).json({ error: 'Device not found' });
|
||||||
|
res.status(200).json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating device timezone:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/group_timezone', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { groupId } = req.query;
|
||||||
|
if (!groupId) return res.status(400).json({ error: 'groupId is required' });
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'SELECT timezone FROM groups WHERE id=$1 AND user_id=$2',
|
||||||
|
[groupId, req.user]
|
||||||
|
);
|
||||||
|
if (rows.length === 0) return res.status(404).json({ error: 'Group not found' });
|
||||||
|
res.status(200).json({ timezone: rows[0].timezone });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching group timezone:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/update_group_timezone', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { groupId, timezone } = req.body;
|
||||||
|
if (!groupId || !timezone) return res.status(400).json({ error: 'Missing required fields' });
|
||||||
|
try {
|
||||||
|
cronParser.parseExpression('0 0 * * 0', { tz: timezone });
|
||||||
|
} catch {
|
||||||
|
return res.status(400).json({ error: 'Invalid IANA timezone' });
|
||||||
|
}
|
||||||
|
const result = await pool.query(
|
||||||
|
'UPDATE groups SET timezone=$1 WHERE id=$2 AND user_id=$3',
|
||||||
|
[timezone, groupId, req.user]
|
||||||
|
);
|
||||||
|
if (result.rowCount === 0) return res.status(404).json({ error: 'Group not found' });
|
||||||
|
res.status(200).json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating group timezone:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/rename_group', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { groupId, newName } = req.body;
|
||||||
|
if (!groupId || !newName || !newName.trim()) return res.status(400).json({ error: 'Missing required fields' });
|
||||||
|
const result = await pool.query(
|
||||||
|
'UPDATE groups SET name=$1 WHERE id=$2 AND user_id=$3',
|
||||||
|
[newName.trim(), groupId, req.user]
|
||||||
|
);
|
||||||
|
if (result.rowCount === 0) return res.status(404).json({ error: 'Group not found' });
|
||||||
|
res.status(200).json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === '23505') return res.status(409).json({ error: 'A group with this name already exists' });
|
||||||
|
console.error('Error renaming group:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
260
public/app/index.html
Normal file
260
public/app/index.html
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>BlindMaster App — Coming Soon</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--accent: #3B82F6;
|
||||||
|
--accent-rgb: 59, 130, 246;
|
||||||
|
--bg: #0A0B0F;
|
||||||
|
--bg-card: #181B24;
|
||||||
|
--text: #F1F5F9;
|
||||||
|
--text-muted: #8892A4;
|
||||||
|
--text-faint: #4B5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Poppins', sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Background grid */
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px);
|
||||||
|
background-size: 48px 48px;
|
||||||
|
mask-image: radial-gradient(ellipse 70% 70% at 50% 50%, black 30%, transparent 100%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glow */
|
||||||
|
body::after {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
top: 50%; left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 600px; height: 400px;
|
||||||
|
background: radial-gradient(ellipse, rgba(var(--accent-rgb), 0.12) 0%, transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid rgba(255,255,255,0.07);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 56px 48px;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 480px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 32px 80px rgba(0,0,0,0.5);
|
||||||
|
animation: fadeUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeUp {
|
||||||
|
from { opacity: 0; transform: translateY(24px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
width: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-slat {
|
||||||
|
height: 3px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
background: rgba(var(--accent-rgb), 0.12);
|
||||||
|
border: 1px solid rgba(var(--accent-rgb), 0.25);
|
||||||
|
padding: 5px 14px;
|
||||||
|
border-radius: 100px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-dot {
|
||||||
|
width: 6px; height: 6px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse 1.8s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.4; transform: scale(0.75); }
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 span {
|
||||||
|
background: linear-gradient(135deg, var(--accent), #a78bfa);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.7;
|
||||||
|
margin-bottom: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border-radius: 100px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
width: 72%;
|
||||||
|
background: linear-gradient(90deg, var(--accent), rgba(var(--accent-rgb), 0.4));
|
||||||
|
border-radius: 100px;
|
||||||
|
animation: shimmer 2s infinite;
|
||||||
|
background-size: 200% 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 200% center; }
|
||||||
|
100% { background-position: -200% center; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-label {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-align: right;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover { color: var(--text); }
|
||||||
|
|
||||||
|
.back-link svg { transition: transform 0.2s; }
|
||||||
|
.back-link:hover svg { transform: translateX(-3px); }
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.card { padding: 40px 24px; }
|
||||||
|
h1 { font-size: 1.6rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="logo">
|
||||||
|
<div class="logo-icon">
|
||||||
|
<div class="logo-slat"></div>
|
||||||
|
<div class="logo-slat"></div>
|
||||||
|
<div class="logo-slat"></div>
|
||||||
|
<div class="logo-slat"></div>
|
||||||
|
</div>
|
||||||
|
BlindMaster
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="badge">
|
||||||
|
<span class="badge-dot"></span>
|
||||||
|
In Development
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>The web app is<br /><span>coming soon.</span></h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
We're building the BlindMaster web experience so you can control
|
||||||
|
your blinds from any browser — no install required. Stay tuned.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-label">Development in progress</div>
|
||||||
|
|
||||||
|
<a href="/" class="back-link">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||||
|
<path d="M19 12H5M12 5l-7 7 7 7"/>
|
||||||
|
</svg>
|
||||||
|
Back to homepage
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Match time-based accent color from main landing page
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
let accent, accentRgb;
|
||||||
|
if (hour >= 5 && hour < 10) {
|
||||||
|
accent = '#F97316'; accentRgb = '249, 115, 22';
|
||||||
|
} else if (hour >= 10 && hour < 18) {
|
||||||
|
accent = '#3B82F6'; accentRgb = '59, 130, 246';
|
||||||
|
} else {
|
||||||
|
accent = '#7C3AED'; accentRgb = '124, 58, 237';
|
||||||
|
}
|
||||||
|
document.documentElement.style.setProperty('--accent', accent);
|
||||||
|
document.documentElement.style.setProperty('--accent-rgb', accentRgb);
|
||||||
|
// Update slats to match accent
|
||||||
|
document.querySelectorAll('.logo-slat').forEach(s => s.style.background = accent);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
934
public/css/styles.css
Normal file
934
public/css/styles.css
Normal file
@@ -0,0 +1,934 @@
|
|||||||
|
/* ============================================================
|
||||||
|
BlindMaster Landing Page — Styles
|
||||||
|
Theme: time-based accent (orange / blue / purple)
|
||||||
|
matching the Flutter app's time-of-day color system
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ---------- CSS Custom Properties (set by JS at runtime) --- */
|
||||||
|
:root {
|
||||||
|
--accent: #3B82F6; /* default: day blue */
|
||||||
|
--accent-rgb: 59, 130, 246;
|
||||||
|
--accent-dark: #1D4ED8;
|
||||||
|
--accent-glow: rgba(59, 130, 246, 0.25);
|
||||||
|
|
||||||
|
--bg: #0A0B0F;
|
||||||
|
--bg-surface: #12141A;
|
||||||
|
--bg-card: #181B24;
|
||||||
|
--bg-card-border: rgba(255,255,255,0.07);
|
||||||
|
|
||||||
|
--text: #F1F5F9;
|
||||||
|
--text-muted: #8892A4;
|
||||||
|
--text-faint: #4B5563;
|
||||||
|
|
||||||
|
--slat-color: #6B4C2A;
|
||||||
|
--slat-shadow: rgba(0,0,0,0.4);
|
||||||
|
|
||||||
|
--transition: 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
--radius: 16px;
|
||||||
|
--radius-sm: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Reset & Base ----------------------------------- */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
html { scroll-behavior: smooth; font-size: 16px; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Poppins', sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
overflow-x: hidden;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
img { display: block; max-width: 100%; }
|
||||||
|
button { font-family: inherit; cursor: pointer; border: none; }
|
||||||
|
|
||||||
|
/* ---------- Utility ---------------------------------------- */
|
||||||
|
.section-inner {
|
||||||
|
max-width: 1160px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-eyebrow {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: clamp(1.8rem, 4vw, 2.8rem);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-sub {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header { margin-bottom: 56px; }
|
||||||
|
|
||||||
|
/* ---------- Buttons ---------------------------------------- */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 13px 28px;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 0 24px var(--accent-glow);
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
filter: brightness(1.12);
|
||||||
|
box-shadow: 0 0 36px var(--accent-glow);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1.5px solid var(--bg-card-border);
|
||||||
|
}
|
||||||
|
.btn-ghost:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: rgba(255,255,255,0.18);
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Scroll reveal ---------------------------------- */
|
||||||
|
.reveal {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(28px);
|
||||||
|
transition: opacity 0.7s ease, transform 0.7s ease;
|
||||||
|
transition-delay: var(--delay, 0ms);
|
||||||
|
}
|
||||||
|
.reveal.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
NAV
|
||||||
|
============================================================ */
|
||||||
|
.nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
padding: 16px 0;
|
||||||
|
transition: background 0.3s, backdrop-filter 0.3s, box-shadow 0.3s;
|
||||||
|
}
|
||||||
|
.nav.scrolled {
|
||||||
|
background: rgba(10, 11, 15, 0.85);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
box-shadow: 0 1px 0 rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.nav-inner {
|
||||||
|
max-width: 1160px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.nav-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
.nav-logo-icon {
|
||||||
|
width: 26px;
|
||||||
|
height: 22px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.nav-logo-icon.small { width: 20px; height: 18px; gap: 3px; }
|
||||||
|
.slat {
|
||||||
|
height: 3px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 32px;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.nav-links a {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.nav-links a:hover { color: var(--text); }
|
||||||
|
.nav-cta {
|
||||||
|
background: var(--accent) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 0.85rem !important;
|
||||||
|
transition: filter 0.2s, box-shadow 0.2s !important;
|
||||||
|
}
|
||||||
|
.nav-cta:hover { filter: brightness(1.1); }
|
||||||
|
.nav-launch {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text) !important;
|
||||||
|
border: 1.5px solid var(--bg-card-border);
|
||||||
|
padding: 7px 18px;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 0.85rem !important;
|
||||||
|
transition: border-color 0.2s, background 0.2s !important;
|
||||||
|
}
|
||||||
|
.nav-launch:hover {
|
||||||
|
border-color: rgba(255,255,255,0.2) !important;
|
||||||
|
background: rgba(255,255,255,0.05) !important;
|
||||||
|
}
|
||||||
|
.mobile-nav-launch {
|
||||||
|
color: var(--accent) !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
}
|
||||||
|
.nav-hamburger {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
background: transparent;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
.nav-hamburger span {
|
||||||
|
display: block;
|
||||||
|
width: 22px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--text-muted);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 64px; left: 0; right: 0;
|
||||||
|
background: rgba(10,11,15,0.97);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border-bottom: 1px solid var(--bg-card-border);
|
||||||
|
z-index: 99;
|
||||||
|
padding: 16px 0;
|
||||||
|
transform: translateY(-16px);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.25s, transform 0.25s;
|
||||||
|
}
|
||||||
|
.mobile-nav.open {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: all;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.mobile-nav ul { list-style: none; }
|
||||||
|
.mobile-nav-link {
|
||||||
|
display: block;
|
||||||
|
padding: 14px 24px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.mobile-nav-link:hover { color: var(--text); }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
HERO
|
||||||
|
============================================================ */
|
||||||
|
.hero {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 120px 24px 80px;
|
||||||
|
max-width: 1160px;
|
||||||
|
margin: 0 auto;
|
||||||
|
gap: 48px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.hero-bg-grid {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px);
|
||||||
|
background-size: 48px 48px;
|
||||||
|
mask-image: radial-gradient(ellipse 80% 60% at 50% 40%, black 30%, transparent 100%);
|
||||||
|
}
|
||||||
|
.hero-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.hero-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
background: rgba(var(--accent-rgb), 0.12);
|
||||||
|
border: 1px solid rgba(var(--accent-rgb), 0.25);
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 100px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.badge-dot {
|
||||||
|
width: 6px; height: 6px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.5; transform: scale(0.8); }
|
||||||
|
}
|
||||||
|
.hero-title {
|
||||||
|
font-size: clamp(2.4rem, 6vw, 4.2rem);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.1;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.hero-accent {
|
||||||
|
background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 60%, #fff));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
.hero-subtitle {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-width: 500px;
|
||||||
|
margin-bottom: 36px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.hero-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
.hero-stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
.stat { text-align: center; }
|
||||||
|
.stat-num {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin-top: 4px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.stat-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 36px;
|
||||||
|
background: var(--bg-card-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Hero visual: window + blind ---- */
|
||||||
|
.hero-visual {
|
||||||
|
flex: 0 0 420px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.window-frame {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 32px 80px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.04) inset;
|
||||||
|
}
|
||||||
|
.window-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
border-bottom: 1px solid var(--bg-card-border);
|
||||||
|
}
|
||||||
|
.window-dot {
|
||||||
|
width: 12px; height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.window-dot.red { background: #FF5F57; }
|
||||||
|
.window-dot.yellow { background: #FEBC2E; }
|
||||||
|
.window-dot.green { background: #28C840; }
|
||||||
|
.window-title {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.blind-container {
|
||||||
|
height: 220px;
|
||||||
|
background: linear-gradient(180deg, #87CEEB 0%, #B0D4F1 100%);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.blind-slat {
|
||||||
|
height: 18px;
|
||||||
|
background: var(--slat-color);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin: 0 12px;
|
||||||
|
box-shadow: 0 2px 4px var(--slat-shadow);
|
||||||
|
transform-origin: center;
|
||||||
|
transition: transform 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
transform: rotateX(calc(var(--i, 0) * 0deg));
|
||||||
|
}
|
||||||
|
.app-ui-overlay {
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
.app-slider-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.app-slider-track {
|
||||||
|
height: 6px;
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
border-radius: 100px;
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.app-slider-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.3);
|
||||||
|
transition: left 0.5s cubic-bezier(0.34, 1.56, 0.64, 1), background var(--transition);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.app-slider-vals {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
FEATURES
|
||||||
|
============================================================ */
|
||||||
|
.features {
|
||||||
|
padding: 120px 0;
|
||||||
|
background: linear-gradient(180deg, var(--bg) 0%, var(--bg-surface) 100%);
|
||||||
|
}
|
||||||
|
.features-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.feature-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 28px;
|
||||||
|
transition: border-color 0.25s, box-shadow 0.25s, transform 0.25s;
|
||||||
|
}
|
||||||
|
.feature-card:hover {
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.35);
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.3), 0 0 0 1px rgba(var(--accent-rgb), 0.1) inset;
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
.feature-icon {
|
||||||
|
width: 48px; height: 48px;
|
||||||
|
background: rgba(var(--accent-rgb), 0.12);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
color: var(--accent);
|
||||||
|
transition: background var(--transition), color var(--transition);
|
||||||
|
}
|
||||||
|
.feature-icon svg { width: 22px; height: 22px; }
|
||||||
|
.feature-card h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.feature-card p {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
HOW IT WORKS
|
||||||
|
============================================================ */
|
||||||
|
.how-it-works {
|
||||||
|
padding: 120px 0;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Arch flow */
|
||||||
|
.arch-flow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
.arch-node {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 180px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 24px 20px;
|
||||||
|
text-align: center;
|
||||||
|
transition: border-color 0.25s;
|
||||||
|
}
|
||||||
|
.arch-node-center {
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.35);
|
||||||
|
box-shadow: 0 0 40px rgba(var(--accent-rgb), 0.08);
|
||||||
|
}
|
||||||
|
.arch-icon {
|
||||||
|
width: 52px; height: 52px;
|
||||||
|
border-radius: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 14px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.app-icon { background: rgba(var(--accent-rgb), 0.12); }
|
||||||
|
.server-icon { background: rgba(var(--accent-rgb), 0.18); }
|
||||||
|
.device-icon { background: rgba(var(--accent-rgb), 0.12); }
|
||||||
|
.arch-icon svg { width: 24px; height: 24px; }
|
||||||
|
.arch-node h4 { font-size: 0.95rem; font-weight: 600; margin-bottom: 8px; }
|
||||||
|
.arch-node p { font-size: 0.78rem; color: var(--text-muted); line-height: 1.6; }
|
||||||
|
.arch-tag {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--accent);
|
||||||
|
background: rgba(var(--accent-rgb), 0.1);
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 100px;
|
||||||
|
display: inline-block;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
.arch-arrow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.arch-arrow-line {
|
||||||
|
width: 40px;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, var(--bg-card-border), rgba(var(--accent-rgb), 0.4));
|
||||||
|
}
|
||||||
|
.arch-arrow-label {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
writing-mode: horizontal-tb;
|
||||||
|
}
|
||||||
|
.arch-ble-note {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-bottom: 56px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Steps */
|
||||||
|
.steps-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.step {
|
||||||
|
padding: 24px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.step-num {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: rgba(var(--accent-rgb), 0.2);
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.step h4 { font-size: 0.95rem; font-weight: 600; margin-bottom: 10px; }
|
||||||
|
.step p { font-size: 0.85rem; color: var(--text-muted); line-height: 1.65; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
TECH / OPEN HARDWARE
|
||||||
|
============================================================ */
|
||||||
|
.tech {
|
||||||
|
padding: 120px 0;
|
||||||
|
background: linear-gradient(180deg, var(--bg-surface) 0%, var(--bg) 100%);
|
||||||
|
}
|
||||||
|
.tech-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 64px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.tech-text { flex: 1; min-width: 280px; }
|
||||||
|
.tech-text .section-title { margin-bottom: 20px; }
|
||||||
|
.tech-text p {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.75;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.tech-list {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.tech-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.tech-check {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chip visual */
|
||||||
|
.tech-visual {
|
||||||
|
flex: 0 0 280px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.chip-card {
|
||||||
|
position: relative;
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
.chip-glow {
|
||||||
|
position: absolute;
|
||||||
|
inset: -40px;
|
||||||
|
background: radial-gradient(ellipse at 50% 50%, rgba(var(--accent-rgb), 0.18) 0%, transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
.chip-body {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--bg-card-border);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 24px 20px;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.chip-label {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.chip-sub {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.chip-pins {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.chip-pin-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.chip-pin {
|
||||||
|
width: 24px;
|
||||||
|
height: 7px;
|
||||||
|
background: rgba(var(--accent-rgb), 0.35);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
.chip-core {
|
||||||
|
flex: 1;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
background: rgba(var(--accent-rgb), 0.08);
|
||||||
|
border: 1px solid rgba(var(--accent-rgb), 0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 100px;
|
||||||
|
max-height: 100px;
|
||||||
|
}
|
||||||
|
.chip-core-inner {
|
||||||
|
width: 60%;
|
||||||
|
height: 60%;
|
||||||
|
background: rgba(var(--accent-rgb), 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(var(--accent-rgb), 0.3);
|
||||||
|
}
|
||||||
|
.chip-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.chip-tags span {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--accent);
|
||||||
|
background: rgba(var(--accent-rgb), 0.1);
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
COMING SOON
|
||||||
|
============================================================ */
|
||||||
|
.coming-soon {
|
||||||
|
padding: 120px 0;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.coming-soon-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid rgba(var(--accent-rgb), 0.2);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 64px 56px;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 0 80px rgba(var(--accent-rgb), 0.06);
|
||||||
|
}
|
||||||
|
.coming-soon-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -80px; left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 400px; height: 200px;
|
||||||
|
background: radial-gradient(ellipse, rgba(var(--accent-rgb), 0.15) 0%, transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.cs-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
background: rgba(var(--accent-rgb), 0.12);
|
||||||
|
border: 1px solid rgba(var(--accent-rgb), 0.25);
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 100px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.cs-badge-dot {
|
||||||
|
width: 6px; height: 6px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
.coming-soon-card h2 {
|
||||||
|
font-size: clamp(1.6rem, 3.5vw, 2.4rem);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.coming-soon-card > p {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 0 auto 36px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.coming-soon-card strong { color: var(--text); }
|
||||||
|
.cs-highlights {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
.cs-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.cs-item svg { color: var(--accent); flex-shrink: 0; }
|
||||||
|
.cs-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.cs-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border: 1.5px solid var(--bg-card-border);
|
||||||
|
border-radius: 100px;
|
||||||
|
padding: 13px 20px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.cs-input::placeholder { color: var(--text-faint); }
|
||||||
|
.cs-input:focus { border-color: var(--accent); }
|
||||||
|
.cs-btn { flex-shrink: 0; }
|
||||||
|
.cs-disclaimer {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.cs-success {
|
||||||
|
color: #4ADE80;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
FOOTER
|
||||||
|
============================================================ */
|
||||||
|
.footer {
|
||||||
|
border-top: 1px solid var(--bg-card-border);
|
||||||
|
padding: 48px 24px;
|
||||||
|
}
|
||||||
|
.footer-inner {
|
||||||
|
max-width: 1160px;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.footer-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.footer-tagline {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
.footer-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.footer-links a {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.footer-links a:hover { color: var(--text-muted); }
|
||||||
|
.footer-copy {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
RESPONSIVE
|
||||||
|
============================================================ */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.hero {
|
||||||
|
flex-direction: column;
|
||||||
|
padding-top: 100px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.hero-subtitle { margin-left: auto; margin-right: auto; }
|
||||||
|
.hero-actions { justify-content: center; }
|
||||||
|
.hero-stats { justify-content: center; }
|
||||||
|
.hero-visual { width: 100%; flex: none; }
|
||||||
|
.window-frame { max-width: 380px; margin: 0 auto; }
|
||||||
|
.nav-links { display: none; }
|
||||||
|
.nav-hamburger { display: flex; }
|
||||||
|
.tech-content { flex-direction: column; }
|
||||||
|
.tech-visual { flex: none; width: 100%; }
|
||||||
|
.arch-flow { gap: 4px; }
|
||||||
|
.arch-arrow-line { width: 24px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.coming-soon-card { padding: 40px 24px; }
|
||||||
|
.arch-flow { flex-direction: column; }
|
||||||
|
.arch-arrow { flex-direction: row; gap: 8px; }
|
||||||
|
.arch-arrow-line { width: 20px; height: 1px; }
|
||||||
|
}
|
||||||
419
public/index.html
Normal file
419
public/index.html
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>BlindMaster — Smart Blinds, Effortlessly Controlled</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="css/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- NAV -->
|
||||||
|
<nav class="nav" id="nav">
|
||||||
|
<div class="nav-inner">
|
||||||
|
<div class="nav-logo">
|
||||||
|
<div class="nav-logo-icon">
|
||||||
|
<div class="slat"></div>
|
||||||
|
<div class="slat"></div>
|
||||||
|
<div class="slat"></div>
|
||||||
|
<div class="slat"></div>
|
||||||
|
</div>
|
||||||
|
<span>BlindMaster</span>
|
||||||
|
</div>
|
||||||
|
<ul class="nav-links">
|
||||||
|
<li><a href="#features">Features</a></li>
|
||||||
|
<li><a href="#how-it-works">How It Works</a></li>
|
||||||
|
<li><a href="#tech">Open Hardware</a></li>
|
||||||
|
<li><a href="#coming-soon" class="nav-cta">Get Early Access</a></li>
|
||||||
|
<li><a href="/app" class="nav-launch">Launch App</a></li>
|
||||||
|
</ul>
|
||||||
|
<button class="nav-hamburger" id="hamburger" aria-label="Menu">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- MOBILE NAV -->
|
||||||
|
<div class="mobile-nav" id="mobileNav">
|
||||||
|
<ul>
|
||||||
|
<li><a href="#features" class="mobile-nav-link">Features</a></li>
|
||||||
|
<li><a href="#how-it-works" class="mobile-nav-link">How It Works</a></li>
|
||||||
|
<li><a href="#tech" class="mobile-nav-link">Open Hardware</a></li>
|
||||||
|
<li><a href="#coming-soon" class="mobile-nav-link">Get Early Access</a></li>
|
||||||
|
<li><a href="/app" class="mobile-nav-link mobile-nav-launch">Launch App</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- HERO -->
|
||||||
|
<section class="hero" id="hero">
|
||||||
|
<div class="hero-bg-grid"></div>
|
||||||
|
<div class="hero-content">
|
||||||
|
<div class="hero-badge">
|
||||||
|
<span class="badge-dot"></span>
|
||||||
|
Smart Home IoT
|
||||||
|
</div>
|
||||||
|
<h1 class="hero-title">
|
||||||
|
Your blinds,<br />
|
||||||
|
<span class="hero-accent">on your schedule.</span>
|
||||||
|
</h1>
|
||||||
|
<p class="hero-subtitle">
|
||||||
|
BlindMaster brings real-time remote control, intelligent scheduling, and
|
||||||
|
seamless IoT integration to your motorized window blinds — from anywhere.
|
||||||
|
</p>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<a href="/app" class="btn btn-primary">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><rect x="5" y="2" width="14" height="20" rx="2"/><path d="M12 18h.01"/></svg>
|
||||||
|
Launch App
|
||||||
|
</a>
|
||||||
|
<a href="#how-it-works" class="btn btn-ghost">See How It Works</a>
|
||||||
|
</div>
|
||||||
|
<div class="hero-stats">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-num">11</span>
|
||||||
|
<span class="stat-label">Positions</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-divider"></div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-num">∞</span>
|
||||||
|
<span class="stat-label">Schedules</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-divider"></div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-num">Real-time</span>
|
||||||
|
<span class="stat-label">Socket.IO Sync</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-visual">
|
||||||
|
<div class="window-frame">
|
||||||
|
<div class="window-header">
|
||||||
|
<div class="window-dot red"></div>
|
||||||
|
<div class="window-dot yellow"></div>
|
||||||
|
<div class="window-dot green"></div>
|
||||||
|
<span class="window-title">BlindMaster</span>
|
||||||
|
</div>
|
||||||
|
<div class="blind-container" id="heroBlind">
|
||||||
|
<div class="blind-slat" style="--i:0"></div>
|
||||||
|
<div class="blind-slat" style="--i:1"></div>
|
||||||
|
<div class="blind-slat" style="--i:2"></div>
|
||||||
|
<div class="blind-slat" style="--i:3"></div>
|
||||||
|
<div class="blind-slat" style="--i:4"></div>
|
||||||
|
<div class="blind-slat" style="--i:5"></div>
|
||||||
|
<div class="blind-slat" style="--i:6"></div>
|
||||||
|
<div class="blind-slat" style="--i:7"></div>
|
||||||
|
<div class="blind-slat" style="--i:8"></div>
|
||||||
|
<div class="blind-slat" style="--i:9"></div>
|
||||||
|
</div>
|
||||||
|
<div class="app-ui-overlay">
|
||||||
|
<div class="app-slider-label">Position</div>
|
||||||
|
<div class="app-slider-track">
|
||||||
|
<div class="app-slider-thumb" id="sliderThumb"></div>
|
||||||
|
</div>
|
||||||
|
<div class="app-slider-vals">
|
||||||
|
<span>Close ↓</span>
|
||||||
|
<span>Open</span>
|
||||||
|
<span>Close ↑</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FEATURES -->
|
||||||
|
<section class="features" id="features">
|
||||||
|
<div class="section-inner">
|
||||||
|
<div class="section-header reveal">
|
||||||
|
<p class="section-eyebrow">Why BlindMaster</p>
|
||||||
|
<h2 class="section-title">Everything you need to control your light.</h2>
|
||||||
|
</div>
|
||||||
|
<div class="features-grid">
|
||||||
|
|
||||||
|
<div class="feature-card reveal" style="--delay:0ms">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<path d="M12 18.5A6.5 6.5 0 1 0 12 5.5a6.5 6.5 0 0 0 0 13Z"/>
|
||||||
|
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>Real-Time Control</h3>
|
||||||
|
<p>Adjust any blind instantly via WebSocket — sub-second response from your phone to your window, anywhere in the world.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card reveal" style="--delay:80ms">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2"/>
|
||||||
|
<path d="M16 2v4M8 2v4M3 10h18"/>
|
||||||
|
<path d="M8 14h.01M12 14h.01M16 14h.01M8 18h.01M12 18h.01M16 18h.01"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>Smart Scheduling</h3>
|
||||||
|
<p>Set automated cron-based schedules per blind or per group. Wake up to light gradually filling the room — automatically.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card reveal" style="--delay:160ms">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||||
|
<circle cx="9" cy="7" r="4"/>
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>Groups & Rooms</h3>
|
||||||
|
<p>Group multiple blinds and control them together. One tap to raise every blind in a room simultaneously.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card reveal" style="--delay:240ms">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>Secure by Default</h3>
|
||||||
|
<p>JWT authentication, Argon2 password hashing, TLS everywhere, and multi-layer rate limiting — your home stays yours.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card reveal" style="--delay:320ms">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<path d="M23 6l-9.5 9.5-5-5L1 18"/>
|
||||||
|
<path d="M17 6h6v6"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>Auto-Calibration</h3>
|
||||||
|
<p>A guided multi-stage calibration flow maps encoder ticks to your exact blind travel — precise positioning every time.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card reveal" style="--delay:400ms">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<rect x="2" y="7" width="20" height="14" rx="2"/>
|
||||||
|
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/>
|
||||||
|
<line x1="12" y1="12" x2="12" y2="16"/>
|
||||||
|
<line x1="10" y1="14" x2="14" y2="14"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>Battery-Aware</h3>
|
||||||
|
<p>Built-in MAX17048 fuel gauge monitoring with low-battery alerts. Dynamic CPU scaling and servo power gating extend runtime.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- HOW IT WORKS -->
|
||||||
|
<section class="how-it-works" id="how-it-works">
|
||||||
|
<div class="section-inner">
|
||||||
|
<div class="section-header reveal">
|
||||||
|
<p class="section-eyebrow">Architecture</p>
|
||||||
|
<h2 class="section-title">Three components. One seamless system.</h2>
|
||||||
|
<p class="section-sub">BlindMaster is a full-stack IoT platform — every layer is purpose-built to work together.</p>
|
||||||
|
</div>
|
||||||
|
<div class="arch-flow reveal">
|
||||||
|
<div class="arch-node">
|
||||||
|
<div class="arch-icon app-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<rect x="5" y="2" width="14" height="20" rx="2"/>
|
||||||
|
<path d="M12 18h.01"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h4>Flutter App</h4>
|
||||||
|
<p>iOS & Android mobile app. Time-based theming. Real-time slider control. Schedule management.</p>
|
||||||
|
<div class="arch-tag">blinds_flutter</div>
|
||||||
|
</div>
|
||||||
|
<div class="arch-arrow">
|
||||||
|
<div class="arch-arrow-line"></div>
|
||||||
|
<div class="arch-arrow-label">Socket.IO + JWT + TLS</div>
|
||||||
|
<div class="arch-arrow-line"></div>
|
||||||
|
</div>
|
||||||
|
<div class="arch-node arch-node-center">
|
||||||
|
<div class="arch-icon server-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<rect x="2" y="2" width="20" height="8" rx="2"/>
|
||||||
|
<rect x="2" y="14" width="20" height="8" rx="2"/>
|
||||||
|
<line x1="6" y1="6" x2="6.01" y2="6"/>
|
||||||
|
<line x1="6" y1="18" x2="6.01" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h4>Express Server</h4>
|
||||||
|
<p>Central relay & API. PostgreSQL + MongoDB. Agenda scheduling. Email via AWS SES.</p>
|
||||||
|
<div class="arch-tag">blinds_express</div>
|
||||||
|
</div>
|
||||||
|
<div class="arch-arrow">
|
||||||
|
<div class="arch-arrow-line"></div>
|
||||||
|
<div class="arch-arrow-label">Socket.IO + JWT + TLS</div>
|
||||||
|
<div class="arch-arrow-line"></div>
|
||||||
|
</div>
|
||||||
|
<div class="arch-node">
|
||||||
|
<div class="arch-icon device-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<path d="M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2V9M9 21H5a2 2 0 0 1-2-2V9m0 0h18"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h4>ESP32-C6</h4>
|
||||||
|
<p>FreeRTOS firmware. BLE provisioning. Servo + encoder control. NVS persistent state.</p>
|
||||||
|
<div class="arch-tag">Blinds_XIAO</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="arch-ble-note reveal">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="16" height="16">
|
||||||
|
<path d="M7 7l10 10M17 7l-5 5 5 5M7 7l5 5-5 5"/>
|
||||||
|
</svg>
|
||||||
|
BLE provisioning (setup only) — phone pairs directly to device to deliver Wi-Fi credentials & auth token
|
||||||
|
</div>
|
||||||
|
<div class="steps-grid reveal">
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-num">01</div>
|
||||||
|
<h4>Provision Once</h4>
|
||||||
|
<p>Pair your ESP32-C6 device over BLE from the app. Enter your Wi-Fi credentials and authenticate — stored securely on the device in NVS.</p>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-num">02</div>
|
||||||
|
<h4>Calibrate</h4>
|
||||||
|
<p>A guided handshake walks the device through measuring your blind's full travel range. Precise 11-position control from that point on.</p>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-num">03</div>
|
||||||
|
<h4>Control & Schedule</h4>
|
||||||
|
<p>Use the app slider for immediate control or set recurring schedules. Changes reach your blinds in real-time over Socket.IO.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- OPEN HARDWARE -->
|
||||||
|
<section class="tech" id="tech">
|
||||||
|
<div class="section-inner">
|
||||||
|
<div class="tech-content reveal">
|
||||||
|
<div class="tech-text">
|
||||||
|
<p class="section-eyebrow">Open Hardware</p>
|
||||||
|
<h2 class="section-title">Built on the Seeed XIAO ESP32-C6.</h2>
|
||||||
|
<p>BlindMaster hardware is built around the Seeed XIAO ESP32-C6 — a compact, powerful RISC-V module with built-in Wi-Fi 6 and Bluetooth 5.3. The firmware is open-source ESP-IDF, and hardware schematics will be open-sourced at launch.</p>
|
||||||
|
<ul class="tech-list">
|
||||||
|
<li>
|
||||||
|
<span class="tech-check">✓</span>
|
||||||
|
LEDC PWM servo control at 50Hz with encoder-based position feedback
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="tech-check">✓</span>
|
||||||
|
MAX17048 I²C fuel gauge for accurate LiPo state-of-charge monitoring
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="tech-check">✓</span>
|
||||||
|
Dynamic CPU scaling (80–160MHz) + light sleep for extended battery life
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="tech-check">✓</span>
|
||||||
|
NimBLE secure provisioning — no cloud dependency during setup
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="tech-visual">
|
||||||
|
<div class="chip-card">
|
||||||
|
<div class="chip-glow"></div>
|
||||||
|
<div class="chip-body">
|
||||||
|
<div class="chip-label">ESP32-C6</div>
|
||||||
|
<div class="chip-sub">RISC-V · Wi-Fi 6 · BT 5.3</div>
|
||||||
|
<div class="chip-pins">
|
||||||
|
<div class="chip-pin-row left">
|
||||||
|
<div class="chip-pin"></div>
|
||||||
|
<div class="chip-pin"></div>
|
||||||
|
<div class="chip-pin"></div>
|
||||||
|
<div class="chip-pin"></div>
|
||||||
|
<div class="chip-pin"></div>
|
||||||
|
</div>
|
||||||
|
<div class="chip-core">
|
||||||
|
<div class="chip-core-inner"></div>
|
||||||
|
</div>
|
||||||
|
<div class="chip-pin-row right">
|
||||||
|
<div class="chip-pin"></div>
|
||||||
|
<div class="chip-pin"></div>
|
||||||
|
<div class="chip-pin"></div>
|
||||||
|
<div class="chip-pin"></div>
|
||||||
|
<div class="chip-pin"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chip-tags">
|
||||||
|
<span>FreeRTOS</span>
|
||||||
|
<span>ESP-IDF</span>
|
||||||
|
<span>NimBLE</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- COMING SOON -->
|
||||||
|
<section class="coming-soon" id="coming-soon">
|
||||||
|
<div class="section-inner">
|
||||||
|
<div class="coming-soon-card reveal">
|
||||||
|
<div class="cs-badge">
|
||||||
|
<span class="cs-badge-dot"></span>
|
||||||
|
Coming Soon
|
||||||
|
</div>
|
||||||
|
<h2>Ready to take control of your light?</h2>
|
||||||
|
<p>
|
||||||
|
BlindMaster is launching on <strong>TestFlight</strong> for iOS beta testers,
|
||||||
|
with hardware schematics open-sourced at the same time. Join the early access
|
||||||
|
list to be first to know.
|
||||||
|
</p>
|
||||||
|
<div class="cs-highlights">
|
||||||
|
<div class="cs-item">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 12 19.79 19.79 0 0 1 1.61 3.45 2 2 0 0 1 3.59 1h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.91 8.96a16 16 0 0 0 6.29 6.29l.62-.91a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
|
||||||
|
iOS TestFlight Beta
|
||||||
|
</div>
|
||||||
|
<div class="cs-item">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"/></svg>
|
||||||
|
Open-Source Hardware
|
||||||
|
</div>
|
||||||
|
<div class="cs-item">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><rect x="5" y="2" width="14" height="20" rx="2"/><path d="M12 18h.01"/></svg>
|
||||||
|
Android App (following)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form class="cs-form" id="earlyAccessForm">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
class="cs-input"
|
||||||
|
id="emailInput"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="submit" class="btn btn-primary cs-btn">Notify Me</button>
|
||||||
|
</form>
|
||||||
|
<p class="cs-disclaimer">No spam. Just a launch notification.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="footer-inner">
|
||||||
|
<div class="footer-logo">
|
||||||
|
<div class="nav-logo-icon small">
|
||||||
|
<div class="slat"></div>
|
||||||
|
<div class="slat"></div>
|
||||||
|
<div class="slat"></div>
|
||||||
|
<div class="slat"></div>
|
||||||
|
</div>
|
||||||
|
<span>BlindMaster</span>
|
||||||
|
</div>
|
||||||
|
<p class="footer-tagline">Smart blinds. Effortless control.</p>
|
||||||
|
<div class="footer-links">
|
||||||
|
<a href="#features">Features</a>
|
||||||
|
<a href="#how-it-works">How It Works</a>
|
||||||
|
<a href="#tech">Hardware</a>
|
||||||
|
<a href="#coming-soon">Early Access</a>
|
||||||
|
</div>
|
||||||
|
<p class="footer-copy">© 2026 BlindMaster. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
201
public/js/main.js
Normal file
201
public/js/main.js
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
/* ============================================================
|
||||||
|
BlindMaster Landing Page — main.js
|
||||||
|
- Time-based accent color (mirrors Flutter app theming)
|
||||||
|
- Nav scroll behavior
|
||||||
|
- Scroll reveal animations
|
||||||
|
- Hero blind animation
|
||||||
|
- Interactive demo slider
|
||||||
|
- Early access form
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 1. Time-based theme (matches Flutter app color system)
|
||||||
|
// Orange 5–10am | Blue 10am–6pm | Purple 6pm+
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
function getThemeForHour(hour) {
|
||||||
|
if (hour >= 5 && hour < 10) {
|
||||||
|
return {
|
||||||
|
name: 'morning',
|
||||||
|
accent: '#F97316',
|
||||||
|
accentRgb: '249, 115, 22',
|
||||||
|
accentDark: '#C2410C',
|
||||||
|
accentGlow: 'rgba(249, 115, 22, 0.25)',
|
||||||
|
};
|
||||||
|
} else if (hour >= 10 && hour < 18) {
|
||||||
|
return {
|
||||||
|
name: 'day',
|
||||||
|
accent: '#3B82F6',
|
||||||
|
accentRgb: '59, 130, 246',
|
||||||
|
accentDark: '#1D4ED8',
|
||||||
|
accentGlow: 'rgba(59, 130, 246, 0.25)',
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
name: 'evening',
|
||||||
|
accent: '#7C3AED',
|
||||||
|
accentRgb: '124, 58, 237',
|
||||||
|
accentDark: '#5B21B6',
|
||||||
|
accentGlow: 'rgba(124, 58, 237, 0.25)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.style.setProperty('--accent', theme.accent);
|
||||||
|
root.style.setProperty('--accent-rgb', theme.accentRgb);
|
||||||
|
root.style.setProperty('--accent-dark', theme.accentDark);
|
||||||
|
root.style.setProperty('--accent-glow', theme.accentGlow);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
applyTheme(getThemeForHour(hour));
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 2. Nav — scrolled state
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
const nav = document.getElementById('nav');
|
||||||
|
function updateNav() {
|
||||||
|
if (window.scrollY > 40) {
|
||||||
|
nav.classList.add('scrolled');
|
||||||
|
} else {
|
||||||
|
nav.classList.remove('scrolled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('scroll', updateNav, { passive: true });
|
||||||
|
updateNav();
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 3. Mobile nav toggle
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
const hamburger = document.getElementById('hamburger');
|
||||||
|
const mobileNav = document.getElementById('mobileNav');
|
||||||
|
let mobileOpen = false;
|
||||||
|
|
||||||
|
hamburger.addEventListener('click', () => {
|
||||||
|
mobileOpen = !mobileOpen;
|
||||||
|
mobileNav.classList.toggle('open', mobileOpen);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.mobile-nav-link').forEach(link => {
|
||||||
|
link.addEventListener('click', () => {
|
||||||
|
mobileOpen = false;
|
||||||
|
mobileNav.classList.remove('open');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 4. Scroll reveal
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
const revealEls = document.querySelectorAll('.reveal');
|
||||||
|
const revealObserver = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
entry.target.classList.add('visible');
|
||||||
|
revealObserver.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ threshold: 0.12, rootMargin: '0px 0px -40px 0px' }
|
||||||
|
);
|
||||||
|
revealEls.forEach(el => revealObserver.observe(el));
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 5. Hero blind animation
|
||||||
|
// Cycle through positions to demo the 0–10 position model
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
const slats = document.querySelectorAll('.blind-slat');
|
||||||
|
const sliderThumb = document.getElementById('sliderThumb');
|
||||||
|
|
||||||
|
// Position 0–10: map to slat rotation angle
|
||||||
|
// 0 = fully closed (slats flat, blocking light)
|
||||||
|
// 5 = fully open (slats perpendicular, maximum light)
|
||||||
|
// 10 = closed from opposite direction
|
||||||
|
function positionToAngle(pos) {
|
||||||
|
if (pos <= 5) {
|
||||||
|
return (pos / 5) * 75; // 0° → 75°
|
||||||
|
} else {
|
||||||
|
return 75 - ((pos - 5) / 5) * 75; // 75° → 0°
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionToSliderPercent(pos) {
|
||||||
|
return (pos / 10) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBlindPosition(pos) {
|
||||||
|
const angle = positionToAngle(pos);
|
||||||
|
slats.forEach(slat => {
|
||||||
|
slat.style.transform = `rotateX(${angle}deg)`;
|
||||||
|
});
|
||||||
|
sliderThumb.style.left = `${positionToSliderPercent(pos)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate through positions: 0 → 5 → 10 → 5 → 0, looping
|
||||||
|
const demoPositions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1];
|
||||||
|
let demoIndex = 0;
|
||||||
|
|
||||||
|
setBlindPosition(demoPositions[0]);
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
demoIndex = (demoIndex + 1) % demoPositions.length;
|
||||||
|
setBlindPosition(demoPositions[demoIndex]);
|
||||||
|
}, 700);
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 6. Interactive slider on demo window (drag/click)
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
const sliderTrack = document.querySelector('.app-slider-track');
|
||||||
|
|
||||||
|
function handleSliderInteraction(clientX) {
|
||||||
|
const rect = sliderTrack.getBoundingClientRect();
|
||||||
|
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||||
|
const pos = Math.round(pct * 10);
|
||||||
|
setBlindPosition(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
sliderTrack.addEventListener('mousedown', (e) => {
|
||||||
|
handleSliderInteraction(e.clientX);
|
||||||
|
const onMove = (e) => handleSliderInteraction(e.clientX);
|
||||||
|
const onUp = () => {
|
||||||
|
window.removeEventListener('mousemove', onMove);
|
||||||
|
window.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', onMove);
|
||||||
|
window.addEventListener('mouseup', onUp);
|
||||||
|
});
|
||||||
|
|
||||||
|
sliderTrack.addEventListener('touchstart', (e) => {
|
||||||
|
handleSliderInteraction(e.touches[0].clientX);
|
||||||
|
const onMove = (e) => handleSliderInteraction(e.touches[0].clientX);
|
||||||
|
const onEnd = () => {
|
||||||
|
window.removeEventListener('touchmove', onMove);
|
||||||
|
window.removeEventListener('touchend', onEnd);
|
||||||
|
};
|
||||||
|
window.addEventListener('touchmove', onMove);
|
||||||
|
window.addEventListener('touchend', onEnd);
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 7. Early access form
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
const form = document.getElementById('earlyAccessForm');
|
||||||
|
const emailInput = document.getElementById('emailInput');
|
||||||
|
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const email = emailInput.value.trim();
|
||||||
|
if (!email) return;
|
||||||
|
|
||||||
|
// Replace form with success message
|
||||||
|
const successMsg = document.createElement('p');
|
||||||
|
successMsg.className = 'cs-success';
|
||||||
|
successMsg.textContent = `You're on the list! We'll notify ${email} at launch.`;
|
||||||
|
form.replaceWith(successMsg);
|
||||||
|
});
|
||||||
|
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user