update at 2026-03-15 15:58:14

This commit is contained in:
douboer@gmail.com
2026-03-15 15:58:14 +08:00
parent b38d932a05
commit 4b280073d4
24 changed files with 1509 additions and 596 deletions

View File

@@ -5,7 +5,9 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"build": "vue-tsc --noEmit && vite build && node scripts/generate-dashboard-manifest.mjs",
"export:background": "sh scripts/export-kindle-background.sh",
"manifest": "node scripts/generate-dashboard-manifest.mjs",
"typecheck": "vue-tsc --noEmit",
"preview": "vite preview"
},

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)"
CALENDAR_DIR="$ROOT_DIR/calendar"
DIST_DIR="$CALENDAR_DIR/dist"
PORT=${PORT:-4173}
URL=${1:-"http://127.0.0.1:$PORT/?mode=background"}
OUT_PNG=${2:-"$DIST_DIR/kindlebg.png"}
OUT_REGION=${3:-"$DIST_DIR/clock-region.json"}
cd "$CALENDAR_DIR"
npm run build >/dev/null
python3 -m http.server "$PORT" -d "$DIST_DIR" >/tmp/kindle-calendar-http.log 2>&1 &
SERVER_PID=$!
trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT INT TERM
sleep 1
/usr/bin/swift "$CALENDAR_DIR/scripts/export-kindle-background.swift" "$URL" "$OUT_PNG" "$OUT_REGION"
node "$CALENDAR_DIR/scripts/generate-dashboard-manifest.mjs" >/dev/null
cat "$OUT_REGION"

View File

@@ -0,0 +1,186 @@
#!/usr/bin/env swift
import AppKit
import Foundation
import WebKit
enum ExportError: Error, CustomStringConvertible {
case invalidArguments
case snapshotFailed
case pngEncodingFailed(String)
case clockRegionMissing
var description: String {
switch self {
case .invalidArguments:
return "用法export-kindle-background.swift <url> <png-output> <region-output>"
case .snapshotFailed:
return "网页截图失败"
case let .pngEncodingFailed(path):
return "PNG 编码失败:\(path)"
case .clockRegionMissing:
return "页面里没有找到 data-clock-region 标记"
}
}
}
final class SnapshotExporter: NSObject, WKNavigationDelegate {
private let url: URL
private let pngOutputURL: URL
private let regionOutputURL: URL
private let completion: (Result<Void, Error>) -> Void
private let targetSize = CGSize(width: 1024, height: 600)
private lazy var window: NSWindow = {
let window = NSWindow(
contentRect: CGRect(origin: .zero, size: targetSize),
styleMask: [.borderless],
backing: .buffered,
defer: false
)
window.isReleasedWhenClosed = false
window.backgroundColor = .white
return window
}()
private lazy var webView: WKWebView = {
let config = WKWebViewConfiguration()
let view = WKWebView(frame: CGRect(origin: .zero, size: targetSize), configuration: config)
view.navigationDelegate = self
return view
}()
init(url: URL, pngOutputURL: URL, regionOutputURL: URL, completion: @escaping (Result<Void, Error>) -> Void) {
self.url = url
self.pngOutputURL = pngOutputURL
self.regionOutputURL = regionOutputURL
self.completion = completion
super.init()
}
func start() {
window.contentView = webView
window.orderBack(nil)
webView.load(URLRequest(url: url))
}
func webView(_ webView: WKWebView, didFinish _: WKNavigation!) {
//
DispatchQueue.main.asyncAfter(deadline: .now() + 4) {
self.capture()
}
}
private func capture() {
let regionScript = """
(() => {
const node = document.querySelector('[data-clock-region="true"]');
if (!node) return null;
const rect = node.getBoundingClientRect();
return {
x: Math.round(rect.left),
y: Math.round(rect.top),
width: Math.round(rect.width),
height: Math.round(rect.height)
};
})();
"""
webView.evaluateJavaScript(regionScript) { value, error in
if let error {
self.finish(.failure(error))
return
}
guard let region = value as? [String: NSNumber] else {
self.finish(.failure(ExportError.clockRegionMissing))
return
}
let snapshotConfig = WKSnapshotConfiguration()
snapshotConfig.rect = CGRect(origin: .zero, size: self.targetSize)
snapshotConfig.snapshotWidth = NSNumber(value: Float(self.targetSize.width))
self.webView.takeSnapshot(with: snapshotConfig) { image, snapshotError in
if let snapshotError {
self.finish(.failure(snapshotError))
return
}
guard let image else {
self.finish(.failure(ExportError.snapshotFailed))
return
}
do {
try self.savePNG(image: image, to: self.pngOutputURL)
try self.saveRegion(region: region)
self.finish(.success(()))
} catch {
self.finish(.failure(error))
}
}
}
}
private func savePNG(image: NSImage, to url: URL) throws {
let normalizedImage = NSImage(size: NSSize(width: targetSize.width, height: targetSize.height))
normalizedImage.lockFocus()
image.draw(in: NSRect(origin: .zero, size: targetSize))
normalizedImage.unlockFocus()
guard
let tiffRepresentation = normalizedImage.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiffRepresentation),
let pngData = bitmap.representation(using: .png, properties: [:])
else {
throw ExportError.pngEncodingFailed(url.path)
}
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
try pngData.write(to: url)
}
private func saveRegion(region: [String: NSNumber]) throws {
let jsonObject: [String: Int] = [
"x": region["x"]?.intValue ?? 0,
"y": region["y"]?.intValue ?? 0,
"width": region["width"]?.intValue ?? 0,
"height": region["height"]?.intValue ?? 0,
]
let data = try JSONSerialization.data(withJSONObject: jsonObject, options: [.prettyPrinted, .sortedKeys])
try FileManager.default.createDirectory(at: regionOutputURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try data.write(to: regionOutputURL)
}
private func finish(_ result: Result<Void, Error>) {
completion(result)
NSApplication.shared.terminate(nil)
}
}
guard CommandLine.arguments.count >= 4 else {
fputs("\(ExportError.invalidArguments)\n", stderr)
exit(1)
}
let url = URL(string: CommandLine.arguments[1])!
let pngOutputURL = URL(fileURLWithPath: CommandLine.arguments[2])
let regionOutputURL = URL(fileURLWithPath: CommandLine.arguments[3])
let app = NSApplication.shared
app.setActivationPolicy(.prohibited)
let exporter = SnapshotExporter(url: url, pngOutputURL: pngOutputURL, regionOutputURL: regionOutputURL) { result in
switch result {
case .success:
print("Exported \(pngOutputURL.path)")
print("Region saved to \(regionOutputURL.path)")
case let .failure(error):
fputs("\(error)\n", stderr)
exit(1)
}
}
exporter.start()
app.run()

View File

@@ -0,0 +1,50 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const distDir = path.resolve(currentDir, '../dist');
const manifestPath = path.join(distDir, 'dashboard-manifest.json');
const clockRegionPath = path.join(distDir, 'clock-region.json');
const defaultClockRegion = {
x: 313,
y: 0,
width: 220,
height: 220,
};
const clockRegion = fs.existsSync(clockRegionPath)
? {
...defaultClockRegion,
...JSON.parse(fs.readFileSync(clockRegionPath, 'utf8')),
}
: defaultClockRegion;
const manifest = {
background: {
path: 'kindlebg.png',
url: 'https://shell.biboer.cn:20001/kindlebg.png',
updatedAt: new Date().toISOString(),
refreshIntervalMinutes: 120,
},
clockRegion,
clockFace: {
path: 'assets/clock-face.png',
managedOnKindle: true,
designWidth: 220,
designHeight: 220,
},
clockHands: {
hourPattern: 'assets/hour-hand/%03d.png',
minutePattern: 'assets/minute-hand/%02d.png',
refreshIntervalMinutes: 1,
networkRequired: false,
anchorMode: 'baked-into-patch',
scaleWithClockFace: false,
},
};
fs.mkdirSync(distDir, { recursive: true });
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
console.log(`Wrote ${manifestPath}`);

View File

@@ -1,14 +1,17 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import AnalogClock from '@/components/AnalogClock.vue';
import CalendarCard from '@/components/CalendarCard.vue';
import QuoteCard from '@/components/QuoteCard.vue';
import WeatherCard from '@/components/WeatherCard.vue';
import { buildCalendarModel } from '@/lib/calendar';
import { resolveDashboardMode } from '@/lib/dashboard-mode';
import { getQuoteForDate } from '@/lib/quotes';
import { fetchWeather, resolveLocation, type LocationCoordinates, type WeatherSnapshot } from '@/lib/weather';
const now = ref(new Date());
const mode = ref(resolveDashboardMode(window.location.search));
const location = ref<LocationCoordinates>({
latitude: 31.2304,
longitude: 121.4737,
@@ -22,6 +25,7 @@ let weatherTimer = 0;
const calendarModel = computed(() => buildCalendarModel(now.value));
const quoteEntry = computed(() => getQuoteForDate(now.value));
const isClockFaceMode = computed(() => mode.value === 'clock-face');
async function refreshWeather() {
weatherStatus.value = 'loading';
@@ -35,31 +39,46 @@ async function refreshWeather() {
}
}
function syncMode() {
mode.value = resolveDashboardMode(window.location.search);
}
onMounted(async () => {
location.value = await resolveLocation();
await refreshWeather();
window.addEventListener('popstate', syncMode);
if (!isClockFaceMode.value) {
location.value = await resolveLocation();
await refreshWeather();
}
clockTimer = window.setInterval(() => {
now.value = new Date();
}, 30 * 1000);
// 天气不需要秒级刷新30 分钟一次足够。
weatherTimer = window.setInterval(() => {
void refreshWeather();
}, 30 * 60 * 1000);
if (!isClockFaceMode.value) {
// 天气数据属于低频区块,只需要半小时刷新一次。
weatherTimer = window.setInterval(() => {
void refreshWeather();
}, 30 * 60 * 1000);
}
});
onBeforeUnmount(() => {
window.clearInterval(clockTimer);
window.clearInterval(weatherTimer);
window.removeEventListener('popstate', syncMode);
});
</script>
<template>
<main class="page-shell">
<div class="dashboard-frame">
<main :class="['page-shell', `page-shell--${mode}`]">
<section v-if="isClockFaceMode" class="clock-face-stage">
<AnalogClock :date="now" mode="clock-face" :size="220" />
</section>
<div v-else class="dashboard-frame">
<div class="dashboard-grid">
<CalendarCard :model="calendarModel" />
<CalendarCard :model="calendarModel" :date="now" :mode="mode" />
<WeatherCard
:weather="weather"
:status="weatherStatus"

View File

@@ -0,0 +1,126 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { DashboardMode } from '@/lib/dashboard-mode';
import {
buildClockState,
CLOCK_FACE_ASSET,
CLOCK_FACE_SOURCE_SIZE,
CLOCK_RENDER_SIZE,
HOUR_HAND_SPEC,
MINUTE_HAND_SPEC,
type ClockHandSpec,
} from '@/lib/clock';
const props = withDefaults(
defineProps<{
date: Date;
mode: DashboardMode;
size?: number;
}>(),
{
size: CLOCK_RENDER_SIZE,
},
);
const clockState = computed(() => buildClockState(props.date));
const scale = computed(() => props.size / CLOCK_FACE_SOURCE_SIZE);
function buildHandStyle(spec: ClockHandSpec, angle: number) {
const scaledWidth = spec.sourceWidth * scale.value;
const scaledHeight = spec.sourceHeight * scale.value;
const left = props.size / 2 - spec.pivotX * scale.value;
const top = props.size / 2 - spec.pivotY * scale.value;
return {
width: `${scaledWidth}px`,
height: `${scaledHeight}px`,
left: `${left}px`,
top: `${top}px`,
transformOrigin: `${spec.pivotX * scale.value}px ${spec.pivotY * scale.value}px`,
transform: `rotate(${angle}deg)`,
};
}
const hourHandStyle = computed(() => buildHandStyle(HOUR_HAND_SPEC, clockState.value.hourAngle));
const minuteHandStyle = computed(() => buildHandStyle(MINUTE_HAND_SPEC, clockState.value.minuteAngle));
const stageStyle = computed(() => ({
width: `${props.size}px`,
height: `${props.size}px`,
}));
</script>
<template>
<div
class="analog-clock"
:class="`analog-clock--${mode}`"
:style="stageStyle"
data-clock-region="true"
>
<div v-if="mode === 'background'" class="analog-clock__placeholder" />
<template v-else>
<img class="analog-clock__face" :src="CLOCK_FACE_ASSET" alt="时钟表盘" />
<img
v-if="mode === 'full'"
class="analog-clock__hand analog-clock__hand--hour"
:src="HOUR_HAND_SPEC.src"
alt=""
aria-hidden="true"
:style="hourHandStyle"
/>
<img
v-if="mode === 'full'"
class="analog-clock__hand analog-clock__hand--minute"
:src="MINUTE_HAND_SPEC.src"
alt=""
aria-hidden="true"
:style="minuteHandStyle"
/>
<span v-if="mode === 'full'" class="analog-clock__sr">{{ clockState.digitalLabel }}</span>
</template>
</div>
</template>
<style scoped>
.analog-clock {
position: relative;
flex: 0 0 auto;
}
.analog-clock__face,
.analog-clock__placeholder {
width: 100%;
height: 100%;
}
.analog-clock__face {
object-fit: contain;
}
.analog-clock__placeholder {
border-radius: 50%;
background:
radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.94), rgba(255, 250, 246, 0.78) 68%, rgba(239, 226, 221, 0.52));
box-shadow:
inset 0 0 0 1px rgba(214, 196, 187, 0.35),
inset 0 0 24px rgba(255, 255, 255, 0.45);
}
.analog-clock__hand {
position: absolute;
max-width: none;
}
.analog-clock__sr {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>

View File

@@ -1,10 +1,14 @@
<script setup lang="ts">
import { computed } from 'vue';
import AnalogClock from '@/components/AnalogClock.vue';
import type { CalendarModel } from '@/lib/calendar';
import type { DashboardMode } from '@/lib/dashboard-mode';
const props = defineProps<{
model: CalendarModel;
date: Date;
mode: DashboardMode;
}>();
const weeks = computed(() => {
@@ -16,55 +20,285 @@ const weeks = computed(() => {
return chunks;
});
function subLabelTone(cell: CalendarModel['cells'][number]) {
return cell.badges[0]?.tone ?? 'lunar';
}
</script>
<template>
<section class="card calendar-card">
<div class="calendar-card__header">
<div class="calendar-card__day">{{ model.largeDay }}</div>
<div class="calendar-card__meta">
<p class="calendar-card__date-line">
<span>{{ model.gregorianLabel }}</span>
<span class="calendar-card__weekday">{{ model.weekdayLabel }}</span>
</p>
<p class="calendar-card__lunar">
<span>{{ model.lunarYearLabel }}</span>
<span>{{ model.lunarDayLabel }}</span>
<span v-if="model.summaryBadges.length" class="calendar-card__badges calendar-card__badges--inline">
<span
v-for="badge in model.summaryBadges"
:key="badge.label"
:class="['calendar-card__badge', `calendar-card__badge--${badge.tone}`]"
>
{{ badge.label }}
</span>
</span>
</p>
<div class="calendar-card__hero">
<div class="calendar-card__headline">
<div class="calendar-card__day">{{ model.largeDay }}</div>
<div class="calendar-card__headline-copy">
<p class="calendar-card__lunar-day">{{ model.lunarDayLabel }}</p>
<p class="calendar-card__weekday">{{ model.weekdayLabel }}</p>
</div>
</div>
<AnalogClock :date="date" :mode="mode" :size="220" />
</div>
<div class="calendar-card__grid">
<div v-for="label in model.weekLabels" :key="label" class="calendar-card__week-label">
{{ label }}
<div class="calendar-card__panel">
<div class="calendar-card__panel-header">
<div>
<p class="calendar-card__panel-title">{{ model.gregorianLabel }}</p>
<p class="calendar-card__panel-subtitle">{{ model.lunarYearLabel }}</p>
</div>
<div v-if="model.summaryBadges.length" class="calendar-card__badges">
<span
v-for="badge in model.summaryBadges"
:key="badge.label"
:class="['calendar-card__badge', `calendar-card__badge--${badge.tone}`]"
>
{{ badge.label }}
</span>
</div>
</div>
<template v-for="week in weeks" :key="week[0].date.toISOString()">
<div
v-for="cell in week"
:key="cell.date.toISOString()"
:class="[
'calendar-card__cell',
{
'calendar-card__cell--muted': !cell.currentMonth,
'calendar-card__cell--today': cell.isToday,
'calendar-card__cell--holiday': cell.badges.some((badge) => badge.tone === 'holiday'),
},
]"
>
<span class="calendar-card__solar">{{ cell.day }}</span>
<span class="calendar-card__lunar-day">{{ cell.subLabel }}</span>
<div class="calendar-card__grid">
<div v-for="label in model.weekLabels" :key="label" class="calendar-card__week-label">
{{ label }}
</div>
</template>
<template v-for="week in weeks" :key="week[0].date.toISOString()">
<div
v-for="cell in week"
:key="cell.date.toISOString()"
:class="[
'calendar-card__cell',
{
'calendar-card__cell--muted': !cell.currentMonth,
'calendar-card__cell--today': cell.isToday,
'calendar-card__cell--holiday': cell.badges.some((badge) => badge.tone === 'holiday'),
},
]"
>
<div class="calendar-card__cell-copy">
<span class="calendar-card__solar">{{ cell.day }}</span>
<span
:class="[
'calendar-card__sub',
`calendar-card__sub--${subLabelTone(cell)}`,
]"
>
{{ cell.subLabel }}
</span>
</div>
</div>
</template>
</div>
</div>
</section>
</template>
<style scoped>
.calendar-card {
display: grid;
grid-template-rows: auto 1fr;
height: 100%;
gap: 0.95rem;
padding: 1.28rem 1.32rem 1.12rem;
}
.calendar-card__hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.9rem;
}
.calendar-card__headline {
display: grid;
align-content: center;
flex: 1 1 auto;
min-width: 0;
gap: 0.18rem;
}
.calendar-card__headline-copy {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
gap: 0.45rem;
}
.calendar-card__day {
font-family:
'Iowan Old Style',
'Baskerville',
serif;
font-size: 5.7rem;
line-height: 0.88;
letter-spacing: -0.08em;
color: #111111;
}
.calendar-card__lunar-day,
.calendar-card__weekday {
margin: 0;
font-size: 1.48rem;
line-height: 1.02;
color: #232323;
white-space: nowrap;
}
.calendar-card__weekday {
font-size: 1.54rem;
}
.calendar-card__panel {
display: grid;
grid-template-rows: auto 1fr;
gap: 0.45rem;
min-height: 0;
padding: 0.74rem 0.9rem 0.84rem;
border-radius: 1.1rem;
background: linear-gradient(180deg, rgba(250, 237, 238, 0.58), rgba(247, 240, 239, 0.86));
}
.calendar-card__panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.calendar-card__panel-title,
.calendar-card__panel-subtitle {
margin: 0;
}
.calendar-card__panel-title {
font-size: 0.9rem;
color: #4c4c4c;
}
.calendar-card__panel-subtitle {
margin-top: 0.2rem;
font-size: 0.76rem;
color: #7a6a61;
}
.calendar-card__badges {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.calendar-card__badge {
padding: 0.12rem 0.42rem;
border-radius: 999px;
font-size: 0.66rem;
line-height: 1.2;
border: 1px solid currentColor;
background: rgba(255, 255, 255, 0.66);
}
.calendar-card__badge--holiday {
color: #8b4a20;
}
.calendar-card__badge--workday {
color: #7c5d2f;
}
.calendar-card__badge--festival {
color: #67503d;
}
.calendar-card__badge--term {
color: #4a6b6f;
}
.calendar-card__grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-auto-rows: minmax(0, 1fr);
column-gap: 0.16rem;
row-gap: 0.04rem;
min-height: 0;
align-content: start;
}
.calendar-card__week-label {
display: grid;
place-items: center;
padding-bottom: 0.08rem;
font-size: 0.7rem;
color: #7b7b7b;
}
.calendar-card__cell {
display: grid;
place-items: center;
min-height: 0;
padding: 0.08rem 0 0.1rem;
border-radius: 0.78rem;
color: #3f454e;
}
.calendar-card__cell-copy {
display: grid;
justify-items: center;
gap: 0.04rem;
min-width: 0;
}
.calendar-card__cell--muted {
color: #b7b0b3;
}
.calendar-card__cell--today {
background: #171717;
color: #ffffff;
}
.calendar-card__cell--holiday .calendar-card__solar {
color: #b76114;
}
.calendar-card__solar {
font-size: 0.92rem;
line-height: 1.05;
}
.calendar-card__sub {
max-width: 100%;
overflow: hidden;
font-size: 0.42rem;
line-height: 1;
color: #8b8b8b;
text-overflow: ellipsis;
white-space: nowrap;
}
.calendar-card__sub--holiday {
color: #b76114;
}
.calendar-card__sub--workday {
color: #7c5d2f;
}
.calendar-card__sub--festival {
color: #67503d;
}
.calendar-card__sub--term {
color: #4a6b6f;
}
.calendar-card__sub--lunar {
color: #8b8b8b;
}
.calendar-card__cell--muted .calendar-card__sub {
color: #c3bbbe;
}
.calendar-card__cell--today .calendar-card__sub {
color: rgba(255, 255, 255, 0.84);
}
</style>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue';
import { QUOTE_ICON_ASSET } from '@/lib/icon-assets';
const props = defineProps<{
quote: string;
}>();
@@ -8,20 +10,62 @@ const props = defineProps<{
const quoteFontSize = computed(() => {
const length = props.quote.length;
if (length > 140) {
return '1.75rem';
if (length > 120) {
return '1.05rem';
}
if (length > 100) {
return '1.95rem';
if (length > 80) {
return '1.22rem';
}
return '2.2rem';
if (length > 48) {
return '1.4rem';
}
return '1.6rem';
});
</script>
<template>
<section class="card quote-card">
<div class="quote-card__header">
<img class="quote-card__icon" :src="QUOTE_ICON_ASSET" alt="" aria-hidden="true" />
<span class="quote-card__title">每日鸡汤</span>
</div>
<p class="quote-card__content" :style="{ fontSize: quoteFontSize }">{{ quote }}</p>
</section>
</template>
<style scoped>
.quote-card {
grid-column: 1 / -1;
display: grid;
gap: 0.34rem;
padding: 0.72rem 1.02rem;
background: linear-gradient(180deg, rgba(255, 248, 230, 0.96), rgba(255, 252, 243, 0.94));
overflow: hidden;
}
.quote-card__header {
display: inline-flex;
align-items: center;
gap: 0.45rem;
color: #c75d00;
}
.quote-card__icon {
width: 0.9rem;
height: 0.9rem;
}
.quote-card__title {
font-size: 0.8rem;
font-weight: 600;
}
.quote-card__content {
margin: 0;
line-height: 1.34;
color: #292929;
}
</style>

View File

@@ -28,7 +28,7 @@ function weatherKind(code: number) {
}
if ([51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82].includes(code)) {
return 'rain';
return [65, 67, 81, 82].includes(code) ? 'heavy-rain' : 'rain';
}
if ([71, 73, 75, 77, 85, 86].includes(code)) {
@@ -42,7 +42,7 @@ function weatherKind(code: number) {
return 'cloudy';
}
const forecast = computed(() => props.weather?.forecast.slice(0, 5) ?? []);
const forecast = computed(() => props.weather?.forecast.slice(0, 4) ?? []);
const metrics = computed(() => {
if (!props.weather) {
@@ -53,14 +53,12 @@ const metrics = computed(() => {
{
label: '日出',
value: props.weather.sunrise,
accent: 'metric--sunrise',
icon: 'sunrise',
accent: 'metric-pill--sunrise',
},
{
label: '日落',
value: props.weather.sunset,
accent: 'metric--sunset',
icon: 'sunset',
accent: 'metric-pill--sunset',
},
{
label: '空气质量',
@@ -68,14 +66,12 @@ const metrics = computed(() => {
props.weather.aqi === null
? '暂无'
: `${props.weather.aqi}${props.weather.aqiLabel}`,
accent: 'metric--air',
icon: 'air',
accent: 'metric-pill--air',
},
{
label: '能见度',
value: `${props.weather.visibilityKm} km`,
accent: 'metric--visibility',
icon: 'visibility',
accent: 'metric-pill--visibility',
},
] as const;
});
@@ -119,11 +115,11 @@ function forecastKind(day: ForecastDay) {
<div class="weather-card__facts">
<div class="weather-card__fact">
<WeatherGlyph kind="humidity" />
<span class="weather-card__fact-dot weather-card__fact-dot--humidity" />
<span>湿度 {{ weather.humidity }}%</span>
</div>
<div class="weather-card__fact">
<WeatherGlyph kind="wind" />
<span class="weather-card__fact-dot weather-card__fact-dot--wind" />
<span>风速 {{ weather.windSpeed }} km/h</span>
</div>
</div>
@@ -148,7 +144,7 @@ function forecastKind(day: ForecastDay) {
:class="['metric-pill', metric.accent]"
>
<div class="metric-pill__label">
<WeatherGlyph :kind="metric.icon" />
<span class="metric-pill__dot" />
<span>{{ metric.label }}</span>
</div>
<p class="metric-pill__value">{{ metric.value }}</p>
@@ -156,3 +152,213 @@ function forecastKind(day: ForecastDay) {
</div>
</section>
</template>
<style scoped>
.weather-card {
display: grid;
grid-template-rows: auto minmax(0, 1.15fr) minmax(0, 0.9fr) minmax(0, 1fr);
align-content: stretch;
height: 100%;
gap: 0.65rem;
padding: 1.05rem 1.1rem 0.92rem;
overflow: hidden;
}
.weather-card__heading {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.weather-card__title,
.weather-card__subtitle {
margin: 0;
}
.weather-card__title {
font-size: 2.2rem;
font-weight: 700;
color: #111111;
}
.weather-card__subtitle {
margin-top: 0.14rem;
font-size: 1.18rem;
line-height: 1.08;
color: #707070;
}
.weather-card__hero {
display: grid;
grid-template-columns: 1fr;
align-items: center;
gap: 0.8rem;
min-height: 0;
padding: 0.88rem 0.94rem;
border-radius: 1rem;
background: linear-gradient(180deg, #dfeaf8, #d7e4f6);
}
.weather-card__hero--placeholder {
justify-content: center;
min-height: 5.75rem;
color: #617288;
}
.weather-card__hero-main {
display: flex;
align-items: center;
gap: 0.92rem;
min-width: 0;
}
.weather-card__temperature {
font-size: 2.8rem;
line-height: 0.94;
color: #111111;
}
.weather-card__condition {
margin-top: 0.18rem;
font-size: 1.36rem;
line-height: 1.05;
color: #2c3641;
}
.weather-card__facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.28rem;
justify-items: start;
min-width: 0;
}
.weather-card__fact {
display: inline-flex;
align-items: flex-start;
gap: 0.45rem;
color: #617288;
font-size: 1.08rem;
line-height: 1.06;
min-width: 0;
}
.weather-card__fact-dot,
.metric-pill__dot {
width: 0.42rem;
height: 0.42rem;
border-radius: 50%;
background: currentColor;
flex: 0 0 auto;
}
.weather-card__fact-dot--humidity {
color: #5c84be;
}
.weather-card__fact-dot--wind {
color: #6c7e95;
}
.weather-card__forecast {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.46rem;
min-height: 0;
}
.forecast-pill {
display: grid;
justify-items: center;
align-content: center;
gap: 0.34rem;
min-height: 0;
padding: 0.68rem 0.28rem;
border-radius: 1rem;
background: #f8f7f6;
}
.forecast-pill__label,
.forecast-pill__temp {
margin: 0;
}
.forecast-pill__label {
font-size: 1rem;
line-height: 1;
color: #5a5a5a;
}
.forecast-pill__temp {
font-size: 1.24rem;
line-height: 1;
color: #1e1e1e;
}
.forecast-pill__temp span {
margin-left: 0.12rem;
font-size: 1rem;
color: #7a7a7a;
}
.weather-card__metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: 1fr;
gap: 0.46rem;
min-height: 0;
}
.metric-pill {
display: grid;
align-content: center;
gap: 0.3rem;
min-height: 0;
padding: 0.68rem 0.74rem;
border-radius: 0.95rem;
}
.metric-pill__label,
.metric-pill__value {
margin: 0;
}
.metric-pill__label {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.88rem;
line-height: 1.06;
}
.metric-pill__value {
font-size: 1.34rem;
line-height: 1.02;
color: #1c1c1c;
}
.metric-pill--sunrise {
background: #fff1dd;
color: #cb6800;
}
.metric-pill--sunset {
background: #efe4ff;
color: #8e42e8;
}
.metric-pill--air {
background: #dbf5df;
color: #1f9451;
}
.metric-pill--visibility {
background: #d8f4fa;
color: #17779d;
}
.weather-card :deep(.glyph--large) {
width: 2.8rem;
height: 2.8rem;
}
</style>

View File

@@ -1,154 +1,35 @@
<script setup lang="ts">
import { computed } from 'vue';
type WeatherGlyphKind =
| 'clear'
| 'partly'
| 'cloudy'
| 'fog'
| 'rain'
| 'snow'
| 'storm'
| 'sunrise'
| 'sunset'
| 'air'
| 'visibility'
| 'humidity'
| 'wind';
import { weatherIconForKind, type WeatherIconKind } from '@/lib/icon-assets';
const props = defineProps<{
kind: WeatherGlyphKind;
kind: WeatherIconKind;
large?: boolean;
}>();
// 统一收细线宽和轮廓,让图标在墨水屏和小尺寸卡片里更精致、更稳定。
const strokeWidth = computed(() => (props.large ? 1.95 : 1.8));
const source = computed(() => weatherIconForKind(props.kind));
</script>
<template>
<svg
<img
:class="['glyph', { 'glyph--large': props.large }]"
viewBox="0 0 48 48"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
:stroke-width="strokeWidth"
:src="source"
alt=""
aria-hidden="true"
>
<g v-if="props.kind === 'clear'">
<circle cx="24" cy="24" r="6.5" />
<line x1="24" y1="8.5" x2="24" y2="12.5" />
<line x1="24" y1="35.5" x2="24" y2="39.5" />
<line x1="8.5" y1="24" x2="12.5" y2="24" />
<line x1="35.5" y1="24" x2="39.5" y2="24" />
<line x1="13.1" y1="13.1" x2="15.9" y2="15.9" />
<line x1="32.1" y1="32.1" x2="34.9" y2="34.9" />
<line x1="13.1" y1="34.9" x2="15.9" y2="32.1" />
<line x1="32.1" y1="15.9" x2="34.9" y2="13.1" />
</g>
<g v-else-if="props.kind === 'partly'">
<circle cx="16.5" cy="16.5" r="5" />
<line x1="16.5" y1="7.5" x2="16.5" y2="10.5" />
<line x1="9.2" y1="16.5" x2="12.2" y2="16.5" />
<line x1="11.4" y1="11.4" x2="13.6" y2="13.6" />
<line x1="21.6" y1="11.4" x2="19.4" y2="13.6" />
<path d="M15.5 31.5h15.8a5.7 5.7 0 1 0-.5-11.3 8.4 8.4 0 0 0-15.3-1.7 6.2 6.2 0 0 0 0 13Z" />
</g>
<g v-else-if="props.kind === 'cloudy'">
<path d="M13 31.5h18.8a6.3 6.3 0 1 0-.6-12.5 9.4 9.4 0 0 0-17.2-2.1A7 7 0 0 0 13 31.5Z" />
</g>
<g v-else-if="props.kind === 'fog'">
<path d="M13 25.5h18.8a6.3 6.3 0 1 0-.6-12.5 9.4 9.4 0 0 0-17.2-2.1A7 7 0 0 0 13 25.5Z" />
<path d="M11 31c2 0 3-1 5-1s3 1 5 1 3-1 5-1 3 1 5 1 3-1 5-1" />
<path d="M14 36c1.7 0 2.6-.8 4.3-.8s2.6.8 4.3.8 2.6-.8 4.3-.8 2.6.8 4.3.8" />
</g>
<g v-else-if="props.kind === 'rain'">
<path d="M13 26.5h18.8a6.3 6.3 0 1 0-.6-12.5 9.4 9.4 0 0 0-17.2-2.1A7 7 0 0 0 13 26.5Z" />
<path d="M16 31c0 1.9-1.2 3.4-2.7 3.4s-2.7-1.5-2.7-3.4c0-1.6 2.7-4.9 2.7-4.9S16 29.4 16 31Z" />
<path d="M25.4 34.2c0 1.8-1.1 3.2-2.4 3.2s-2.4-1.4-2.4-3.2c0-1.5 2.4-4.5 2.4-4.5s2.4 3 2.4 4.5Z" />
<path d="M34.8 31c0 1.9-1.2 3.4-2.7 3.4s-2.7-1.5-2.7-3.4c0-1.6 2.7-4.9 2.7-4.9s2.7 3.3 2.7 4.9Z" />
</g>
<g v-else-if="props.kind === 'snow'">
<path d="M13 26.5h18.8a6.3 6.3 0 1 0-.6-12.5 9.4 9.4 0 0 0-17.2-2.1A7 7 0 0 0 13 26.5Z" />
<g>
<line x1="16.5" y1="30.5" x2="16.5" y2="38.5" />
<line x1="13" y1="34.5" x2="20" y2="34.5" />
<line x1="13.8" y1="31.8" x2="19.2" y2="37.2" />
<line x1="19.2" y1="31.8" x2="13.8" y2="37.2" />
</g>
<g>
<line x1="31.5" y1="30.5" x2="31.5" y2="38.5" />
<line x1="28" y1="34.5" x2="35" y2="34.5" />
<line x1="28.8" y1="31.8" x2="34.2" y2="37.2" />
<line x1="34.2" y1="31.8" x2="28.8" y2="37.2" />
</g>
</g>
<g v-else-if="props.kind === 'storm'">
<path d="M13 26.5h18.8a6.3 6.3 0 1 0-.6-12.5 9.4 9.4 0 0 0-17.2-2.1A7 7 0 0 0 13 26.5Z" />
<path d="M24.5 29.5l-4 7h4.4l-2 8 7.6-10h-4.8l2.3-5Z" />
</g>
<g v-else-if="props.kind === 'sunrise'">
<line x1="10" y1="33" x2="38" y2="33" />
<path d="M16 33a8 8 0 0 1 16 0" />
<line x1="24" y1="10" x2="24" y2="16" />
<polyline points="21,13 24,10 27,13" />
<line x1="15" y1="26" x2="17.5" y2="23.5" />
<line x1="33" y1="26" x2="30.5" y2="23.5" />
</g>
<g v-else-if="props.kind === 'sunset'">
<line x1="10" y1="33" x2="38" y2="33" />
<path d="M16 33a8 8 0 0 1 16 0" />
<line x1="24" y1="10" x2="24" y2="16" />
<polyline points="21,13 24,16 27,13" />
<line x1="15" y1="26" x2="17.5" y2="28.5" />
<line x1="33" y1="26" x2="30.5" y2="28.5" />
</g>
<g v-else-if="props.kind === 'air'">
<path d="M10 17c4.2 0 4.2-3 8.4-3s4.2 3 8.4 3 4.2-3 8.4-3" />
<path d="M13 24c3.8 0 3.8-2.5 7.6-2.5s3.8 2.5 7.6 2.5 3.8-2.5 7.6-2.5" />
<path d="M10 31c4.2 0 4.2-3 8.4-3s4.2 3 8.4 3 4.2-3 8.4-3" />
</g>
<g v-else-if="props.kind === 'visibility'">
<path d="M6 24c3.1-4.8 8.8-8 18-8s14.9 3.2 18 8c-3.1 4.8-8.8 8-18 8S9.1 28.8 6 24Z" />
<circle cx="24" cy="24" r="4.8" />
<circle cx="24" cy="24" r="1.5" fill="currentColor" stroke="none" />
</g>
<g v-else-if="props.kind === 'humidity'">
<path d="M24 10C20.5 15 16 20.4 16 27a8 8 0 0 0 16 0c0-6.6-4.5-12-8-17Z" />
<path d="M24 30.5a4.6 4.6 0 0 0 4.4-3.1" />
</g>
<g v-else-if="props.kind === 'wind'">
<path d="M10 17h17c3.3 0 5.5-2 5.5-4.8 0-2.4-1.9-4.2-4.4-4.2-2.1 0-3.7 1.2-4.4 3" />
<path d="M10 25h22c2.8 0 4.8 1.8 4.8 4.2 0 2.3-1.8 4.1-4.1 4.1-1.9 0-3.4-.9-4.2-2.4" />
<path d="M10 33h11c2.5 0 4.3 1.6 4.3 3.8 0 2.1-1.7 3.7-3.9 3.7-1.6 0-2.9-.7-3.7-2" />
</g>
</svg>
/>
</template>
<style scoped>
.glyph {
width: 1.12rem;
height: 1.12rem;
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
color: currentColor;
overflow: visible;
object-fit: contain;
}
.glyph--large {
width: 2.7rem;
height: 2.7rem;
width: 2.5rem;
height: 2.5rem;
}
</style>

10
calendar/src/env.d.ts vendored
View File

@@ -9,3 +9,13 @@ declare module '*.md?raw' {
const content: string;
export default content;
}
declare module '*.svg' {
const content: string;
export default content;
}
declare module '*.png' {
const content: string;
export default content;
}

51
calendar/src/lib/clock.ts Normal file
View File

@@ -0,0 +1,51 @@
import clockFaceAsset from '../../../assets/clock-face.png';
import hourHandAsset from '../../../assets/hour-hand.png';
import minuteHandAsset from '../../../assets/minite-hand.png';
export interface ClockHandSpec {
src: string;
sourceWidth: number;
sourceHeight: number;
pivotX: number;
pivotY: number;
}
export interface ClockState {
hourAngle: number;
minuteAngle: number;
digitalLabel: string;
}
// 这组尺寸直接来自仓库里的 PNG 素材Kindle 侧也复用同一套基准。
export const CLOCK_FACE_SOURCE_SIZE = 431;
export const CLOCK_RENDER_SIZE = 220;
// 锚点按 Figma annotation 的中心点拟合到现有 PNG保证网页预览与 Kindle 贴图一致。
export const HOUR_HAND_SPEC: ClockHandSpec = {
src: hourHandAsset,
sourceWidth: 32,
sourceHeight: 205,
pivotX: 13,
pivotY: 138,
};
export const MINUTE_HAND_SPEC: ClockHandSpec = {
src: minuteHandAsset,
sourceWidth: 32,
sourceHeight: 288,
pivotX: 15,
pivotY: 203,
};
export const CLOCK_FACE_ASSET = clockFaceAsset;
export function buildClockState(date: Date): ClockState {
const hour = date.getHours() % 12;
const minute = date.getMinutes();
return {
hourAngle: hour * 30 + minute * 0.5,
minuteAngle: minute * 6,
digitalLabel: `${String(date.getHours()).padStart(2, '0')}:${String(minute).padStart(2, '0')}`,
};
}

View File

@@ -0,0 +1,14 @@
export type DashboardMode = 'full' | 'background' | 'clock-face';
const VALID_MODES = new Set<DashboardMode>(['full', 'background', 'clock-face']);
export function resolveDashboardMode(search: string): DashboardMode {
const params = new URLSearchParams(search);
const mode = params.get('mode');
if (mode && VALID_MODES.has(mode as DashboardMode)) {
return mode as DashboardMode;
}
return 'full';
}

View File

@@ -0,0 +1,39 @@
import bookIcon from '../../../assets/书摘.svg';
import cloudyIcon from '../../../assets/多云.svg';
import heavyRainIcon from '../../../assets/大雨.svg';
import snowIcon from '../../../assets/大雪.svg';
import lightRainIcon from '../../../assets/小雨.svg';
import nightIcon from '../../../assets/晚上.svg';
import clearIcon from '../../../assets/晴天.svg';
import sleetIcon from '../../../assets/雨夹雪.svg';
export type WeatherIconKind =
| 'clear'
| 'partly'
| 'cloudy'
| 'fog'
| 'rain'
| 'heavy-rain'
| 'snow'
| 'storm'
| 'sleet'
| 'night';
const WEATHER_ICON_MAP: Record<WeatherIconKind, string> = {
clear: clearIcon,
partly: cloudyIcon,
cloudy: cloudyIcon,
fog: cloudyIcon,
rain: lightRainIcon,
'heavy-rain': heavyRainIcon,
snow: snowIcon,
storm: heavyRainIcon,
sleet: sleetIcon,
night: nightIcon,
};
export function weatherIconForKind(kind: WeatherIconKind) {
return WEATHER_ICON_MAP[kind];
}
export const QUOTE_ICON_ASSET = bookIcon;

View File

@@ -4,11 +4,11 @@
'PingFang SC',
'Noto Sans SC',
sans-serif;
color: #0f172a;
color: #111111;
background:
radial-gradient(circle at 18% 12%, rgba(255, 255, 255, 0.88), transparent 30%),
radial-gradient(circle at 82% 18%, rgba(241, 238, 232, 0.92), transparent 24%),
linear-gradient(160deg, #f4f1eb 0%, #ebe5dc 52%, #e4ddd4 100%);
radial-gradient(circle at 12% 10%, rgba(255, 255, 255, 0.9), transparent 26%),
radial-gradient(circle at 88% 18%, rgba(255, 255, 255, 0.74), transparent 22%),
linear-gradient(150deg, #fbe9e8 0%, #f2d7d7 48%, #eed6d5 100%);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
@@ -30,419 +30,74 @@ body {
min-height: 100vh;
}
img {
display: block;
}
.page-shell {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
padding: 0.75rem;
}
.page-shell--clock-face {
background: #f5f2ef;
}
.dashboard-frame {
width: min(100%, 1448px);
aspect-ratio: 1448 / 1072;
padding: clamp(1.2rem, 1vw + 0.9rem, 2rem);
border-radius: 2rem;
width: 100%;
aspect-ratio: 1024 / 600;
padding: 1rem;
border-radius: 1.9rem;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.38), rgba(255, 255, 255, 0.18)),
rgba(255, 255, 255, 0.14);
linear-gradient(180deg, rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0.18)),
rgba(255, 255, 255, 0.16);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.55),
0 38px 72px rgba(64, 52, 38, 0.14);
inset 0 1px 0 rgba(255, 255, 255, 0.62),
0 24px 46px rgba(99, 64, 66, 0.18);
backdrop-filter: blur(14px);
}
.dashboard-grid {
display: grid;
grid-template-columns: 1.08fr 1fr;
grid-template-rows: auto auto;
gap: clamp(1rem, 1vw + 0.75rem, 2rem);
grid-template-columns: minmax(0, 1fr) minmax(0, 0.95fr);
grid-template-rows: minmax(0, 1fr) 72px;
gap: 1rem;
height: 100%;
align-content: start;
align-items: stretch;
}
.card {
border-radius: 1.6rem;
min-height: 0;
border-radius: 1.75rem;
background: rgba(255, 255, 255, 0.94);
box-shadow:
0 20px 36px rgba(72, 58, 44, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.65);
border: 1px solid rgba(70, 58, 46, 0.08);
0 16px 34px rgba(92, 67, 60, 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.72);
border: 1px solid rgba(117, 80, 76, 0.08);
}
.calendar-card,
.weather-card {
padding: clamp(1.4rem, 1.2vw + 1rem, 2rem);
min-height: 0;
height: 100%;
}
.calendar-card {
display: grid;
grid-template-rows: auto 1fr;
gap: 0.8rem;
}
.calendar-card__header {
display: flex;
align-items: center;
gap: 1.6rem;
}
.calendar-card__day {
font-family:
'Iowan Old Style',
'Baskerville',
serif;
font-size: clamp(4.4rem, 5vw, 7rem);
line-height: 0.95;
letter-spacing: -0.08em;
color: #101010;
}
.calendar-card__meta {
display: grid;
gap: 0.65rem;
}
.calendar-card__date-line,
.calendar-card__lunar {
display: flex;
gap: 1rem;
align-items: baseline;
margin: 0;
font-size: clamp(1rem, 0.7vw + 0.7rem, 1.55rem);
}
.calendar-card__weekday {
color: #556274;
}
.calendar-card__lunar {
color: #5f4b32;
flex-wrap: wrap;
}
.calendar-card__badges {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.calendar-card__badges--inline {
display: inline-flex;
align-items: center;
margin-left: 0.1rem;
}
.calendar-card__badge {
display: inline-flex;
align-items: center;
min-height: 1.6rem;
padding: 0.2rem 0.55rem;
border-radius: 999px;
border: 1px solid currentColor;
font-size: 0.76rem;
line-height: 1;
}
.calendar-card__badge--holiday {
color: #2f2b26;
background: #ece5db;
}
.calendar-card__badge--workday {
color: #68593f;
background: #f2eadb;
}
.calendar-card__badge--festival {
color: #4f4436;
background: #f5f0e8;
}
.calendar-card__badge--term {
color: #3a4a4a;
background: #e9efef;
}
.calendar-card__grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-template-rows: auto;
grid-auto-rows: minmax(0, 1fr);
gap: 0.35rem 0.3rem;
align-content: start;
height: 100%;
}
.calendar-card__week-label {
text-align: center;
color: #798395;
font-size: 0.92rem;
padding-bottom: 0.45rem;
}
.calendar-card__cell {
min-height: 0;
.clock-face-stage {
display: grid;
place-items: center;
align-content: center;
padding: 0.2rem 0;
border-radius: 0.8rem;
color: #304153;
transition: background-color 150ms ease;
width: min(100%, 320px);
aspect-ratio: 1;
}
.calendar-card__cell--muted {
color: #bac4d2;
}
.calendar-card__cell--today {
background: linear-gradient(180deg, #303640 0%, #1f252d 100%);
color: #ffffff;
box-shadow: 0 14px 24px rgba(31, 37, 45, 0.24);
}
.calendar-card__cell--holiday .calendar-card__solar,
.calendar-card__cell--holiday .calendar-card__lunar-day {
color: #6a4a1c;
}
.calendar-card__solar {
font-size: 1.3rem;
line-height: 1.1;
}
.calendar-card__lunar-day {
font-size: 0.68rem;
line-height: 1.2;
opacity: 0.82;
}
.weather-card {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 0.8rem;
}
.weather-card__heading {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.weather-card__title {
margin: 0;
font-size: clamp(1.5rem, 1vw + 1rem, 2rem);
font-weight: 700;
}
.weather-card__subtitle {
margin: 0.35rem 0 0;
color: #778396;
font-size: 0.95rem;
}
.weather-card__hero {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1.05rem 1.2rem;
border-radius: 1.2rem;
background: linear-gradient(180deg, #ece8e1 0%, #e2ddd4 100%);
}
.weather-card__hero--placeholder {
justify-content: center;
color: #64748b;
}
.weather-card__hero-main {
display: flex;
align-items: center;
gap: 1rem;
}
.weather-card__temperature {
font-size: clamp(2.2rem, 1.2vw + 1.6rem, 3.2rem);
line-height: 1;
font-family:
'Iowan Old Style',
'Baskerville',
serif;
}
.weather-card__condition {
margin-top: 0.35rem;
font-size: 1.15rem;
color: #243041;
}
.weather-card__facts {
display: grid;
gap: 0.45rem;
color: #475569;
}
.weather-card__fact {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.98rem;
}
.weather-card__forecast {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.65rem;
align-items: stretch;
}
.forecast-pill {
display: grid;
justify-items: center;
align-content: center;
gap: 0.45rem;
padding: 0.8rem 0.55rem;
border-radius: 1rem;
background: #f7f4ef;
border: 1px solid rgba(82, 70, 56, 0.08);
height: 100%;
}
.forecast-pill__label {
margin: 0;
color: #556274;
font-size: 0.9rem;
line-height: 1.1;
}
.forecast-pill__temp {
margin: 0;
font-size: 1rem;
font-weight: 700;
}
.forecast-pill__temp span {
margin-left: 0.15rem;
color: #7d8798;
font-weight: 500;
}
.weather-card__metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.65rem;
}
.metric-pill {
display: grid;
align-content: center;
gap: 0.45rem;
padding: 0.85rem 0.9rem 0.9rem;
border-radius: 1rem;
}
.metric-pill__label {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.88rem;
}
.metric-pill__value {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
}
.metric--sunrise {
background: linear-gradient(150deg, #f3eee5, #ebe3d6);
color: #5c4631;
}
.metric--sunset {
background: linear-gradient(150deg, #efebe6, #e5dfd6);
color: #4f463d;
}
.metric--air {
background: linear-gradient(150deg, #eef0eb, #e3e7de);
color: #354338;
}
.metric--visibility {
background: linear-gradient(150deg, #eef1f0, #e2e5e4);
color: #3b4a4d;
}
.quote-card {
grid-column: 1 / -1;
display: grid;
gap: 1.1rem;
padding: clamp(1.5rem, 1.2vw + 1rem, 2rem);
background: linear-gradient(168deg, rgba(249, 245, 236, 0.98), rgba(244, 239, 229, 0.94));
}
.quote-card__content {
margin: 0;
color: #1e293b;
line-height: 1.65;
font-family:
'Iowan Old Style',
'Baskerville',
'Noto Serif SC',
serif;
}
@media (max-width: 1080px) {
.page-shell {
padding: 0.9rem;
}
@media (max-width: 860px) {
.dashboard-frame {
aspect-ratio: auto;
width: min(100%, 1448px);
}
.dashboard-grid {
grid-template-columns: 1fr;
grid-template-rows: auto;
grid-template-rows: none;
}
.quote-card {
grid-column: auto;
}
.weather-card__forecast,
.weather-card__metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.calendar-card__header,
.weather-card__heading,
.weather-card__hero {
grid-template-columns: 1fr;
display: grid;
}
.calendar-card__header {
gap: 1rem;
}
.weather-card__forecast,
.weather-card__metrics {
grid-template-columns: 1fr 1fr;
}
.quote-card__content {
font-size: 1.45rem !important;
.page-shell {
align-items: stretch;
}
}

View File

@@ -8,7 +8,9 @@ Turns out old Kindle devices make great, energy efficient dashboards :-)
This repo only contains the code that runs on the Kindle. It periodically fetches an image to be displayed on the screen and suspends the device to RAM (which is very power efficient) until the next screen update.
This code _does not_ render the dashboard itself. It's expected that what to display on the screen is rendered elsewhere and can be fetchd via HTTP(s). This is both more power efficient and allows you to use any tool you like to produce the dashboard image.
This code _does not_ render the dashboard itself. It's expected that what to display on the screen is rendered elsewhere and can be fetched via HTTP(s). This is both more power efficient and allows you to use any tool you like to produce the dashboard image.
In the current Voyage layered-clock setup, the Kindle only fetches a low-frequency `kindlebg.png` background. The clock face and hand patches are synced as local assets and re-drawn on-device once per minute without network access.
In my case I use a [dashbling](https://github.com/pascalw/dashbling) dashboard that I render into a PNG screenshot on a server. See [here](https://github.com/pascalw/kindle-dash/blob/main/docs/tipstricks.md#producing-dashboard-images-from-a-webpage) for information on how these PNGs should be produced, including some sample code.
@@ -55,8 +57,9 @@ If you're connected over SSH and only want a one-off foreground session, you can
## How this works
* This code periodically downloads a dashboard image from an HTTP(s) endpoint.
* This code periodically downloads a dashboard background image from an HTTP(s) endpoint.
* The interval can be configured in `dist/local/env.sh` using a cron expression.
* When layered clock assets are present, the Kindle re-renders the clock patch locally every minute.
* During the update intervals the device is suspended to RAM to save power.
## Notes
@@ -64,6 +67,7 @@ If you're connected over SSH and only want a one-off foreground session, you can
* The releases contain a pre-compiled binary of the [ht](https://github.com/ducaale/ht) command-line HTTP client. This fully supports modern HTTPS crypto, wheras the built-in `curl` and `wget` commands don't (because they rely on a very old `openssl` library).
* For a detailed Kindle Voyage 5.13.6 jailbreak and deployment walkthrough, see [docs/kindle-voyage-5.13.6-watchthis-zh.md](./docs/kindle-voyage-5.13.6-watchthis-zh.md).
* For a detailed same-device dashboard/SSH troubleshooting playbook based on the 2026-03-15 session, see [docs/kindle-voyage-5.13.6-dual-ssh-playbook-zh.md](./docs/kindle-voyage-5.13.6-dual-ssh-playbook-zh.md).
* For the layered clock split and runtime model, see [docs/layered-clock-plan.zh.md](./docs/layered-clock-plan.zh.md).
## Credits

View File

@@ -3,14 +3,19 @@ DEBUG=${DEBUG:-false}
[ "$DEBUG" = true ] && set -x
DIR="$(dirname "$0")"
DASH_PNG="$DIR/dash.png"
BACKGROUND_PNG="$DIR/kindlebg.png"
FETCH_DASHBOARD_CMD="$DIR/local/fetch-dashboard.sh"
LOW_BATTERY_CMD="$DIR/local/low-battery.sh"
CLOCK_RENDER_CMD="$DIR/local/render-clock.sh"
STATE_DIR="$DIR/local/state"
BACKGROUND_TIMESTAMP_FILE="$STATE_DIR/background-updated-at"
REFRESH_SCHEDULE=${REFRESH_SCHEDULE:-"2,32 8-17 * * MON-FRI"}
FULL_DISPLAY_REFRESH_RATE=${FULL_DISPLAY_REFRESH_RATE:-0}
SLEEP_SCREEN_INTERVAL=${SLEEP_SCREEN_INTERVAL:-3600}
DISABLE_SYSTEM_SUSPEND=${DISABLE_SYSTEM_SUSPEND:-false}
BACKGROUND_REFRESH_INTERVAL_MINUTES=${BACKGROUND_REFRESH_INTERVAL_MINUTES:-120}
CLOCK_FULL_REFRESH_INTERVAL_MINUTES=${CLOCK_FULL_REFRESH_INTERVAL_MINUTES:-15}
RTC=/sys/devices/platform/mxc_rtc.0/wakeup_enable
LOW_BATTERY_REPORTING=${LOW_BATTERY_REPORTING:-false}
@@ -27,6 +32,7 @@ init() {
fi
echo "Starting dashboard with $REFRESH_SCHEDULE refresh..."
mkdir -p "$STATE_DIR"
if [ "$DISABLE_SYSTEM_SUSPEND" = true ]; then
echo "System suspend disabled, using normal sleep between refreshes."
@@ -50,27 +56,72 @@ prepare_sleep() {
num_refresh=$FULL_DISPLAY_REFRESH_RATE
}
refresh_dashboard() {
echo "Refreshing dashboard"
now_epoch() {
date '+%s'
}
background_refresh_due() {
if [ ! -f "$BACKGROUND_PNG" ] || [ ! -f "$BACKGROUND_TIMESTAMP_FILE" ]; then
return 0
fi
current_epoch=$(now_epoch)
last_background_epoch=$(cat "$BACKGROUND_TIMESTAMP_FILE")
refresh_interval_seconds=$((BACKGROUND_REFRESH_INTERVAL_MINUTES * 60))
[ $((current_epoch - last_background_epoch)) -ge "$refresh_interval_seconds" ]
}
store_background_timestamp() {
now_epoch >"$BACKGROUND_TIMESTAMP_FILE"
}
fetch_background() {
echo "Refreshing background"
"$DIR/wait-for-wifi.sh" "$WIFI_TEST_IP"
"$FETCH_DASHBOARD_CMD" "$DASH_PNG"
"$FETCH_DASHBOARD_CMD" "$BACKGROUND_PNG"
fetch_status=$?
if [ "$fetch_status" -ne 0 ]; then
echo "Not updating screen, fetch-dashboard returned $fetch_status"
echo "Background fetch failed with $fetch_status"
return 1
fi
if [ "$num_refresh" -eq "$FULL_DISPLAY_REFRESH_RATE" ]; then
num_refresh=0
store_background_timestamp
return 0
}
# trigger a full refresh once in every 4 refreshes, to keep the screen clean
echo "Full screen refresh"
/usr/sbin/eips -f -g "$DASH_PNG"
clock_force_full_refresh() {
eval "$("$DIR/local/clock-index.sh")"
[ $((minute % CLOCK_FULL_REFRESH_INTERVAL_MINUTES)) -eq 0 ]
}
refresh_dashboard() {
background_refreshed=false
if background_refresh_due; then
if fetch_background; then
background_refreshed=true
echo "Full screen refresh"
/usr/sbin/eips -f -g "$BACKGROUND_PNG"
elif [ ! -f "$BACKGROUND_PNG" ]; then
echo "No cached background available."
return 1
fi
fi
if [ "$background_refreshed" = false ] && [ ! -f "$BACKGROUND_PNG" ]; then
echo "No cached background available."
return 1
fi
if [ "$background_refreshed" = true ] || clock_force_full_refresh; then
echo "Clock patch full refresh"
"$CLOCK_RENDER_CMD" true
else
echo "Partial screen refresh"
/usr/sbin/eips -g "$DASH_PNG"
echo "Clock patch partial refresh"
"$CLOCK_RENDER_CMD" false
fi
num_refresh=$((num_refresh + 1))

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env sh
set -eu
to_decimal() {
printf '%s' "$1" | awk '{
sub(/^0+/, "", $0)
if ($0 == "") {
$0 = 0
}
print $0
}'
}
hour_value=${1:-$(date '+%H')}
minute_value=${2:-$(date '+%M')}
hour_decimal=$(to_decimal "$hour_value")
minute_decimal=$(to_decimal "$minute_value")
hour_index=$(( (hour_decimal % 12) * 60 + minute_decimal ))
printf 'hour=%s\n' "$hour_decimal"
printf 'minute=%s\n' "$minute_decimal"
printf 'hour_index=%03d\n' "$hour_index"
printf 'minute_index=%02d\n' "$minute_decimal"

View File

@@ -5,6 +5,13 @@ export WIFI_TEST_IP=${WIFI_TEST_IP:-1.1.1.1}
# 测试配置:全天每分钟刷新一次,便于验证图片拉取与屏幕刷新是否正常。
export REFRESH_SCHEDULE=${REFRESH_SCHEDULE:-"* * * * *"}
export TIMEZONE=${TIMEZONE:-"Asia/Shanghai"}
export BACKGROUND_URL=${BACKGROUND_URL:-"https://shell.biboer.cn:20001/kindlebg.png"}
export BACKGROUND_REFRESH_INTERVAL_MINUTES=${BACKGROUND_REFRESH_INTERVAL_MINUTES:-120}
export CLOCK_REGION_X=${CLOCK_REGION_X:-262}
export CLOCK_REGION_Y=${CLOCK_REGION_Y:-55}
export CLOCK_REGION_WIDTH=${CLOCK_REGION_WIDTH:-220}
export CLOCK_REGION_HEIGHT=${CLOCK_REGION_HEIGHT:-220}
export CLOCK_FULL_REFRESH_INTERVAL_MINUTES=${CLOCK_FULL_REFRESH_INTERVAL_MINUTES:-15}
# By default, partial screen updates are used to update the screen,
# to prevent the screen from flashing. After a few partial updates,

View File

@@ -1,4 +1,8 @@
#!/usr/bin/env sh
# Fetch a new dashboard image, make sure to output it to "$1".
# For example:
"$(dirname "$0")/../xh" -d -q -o "$1" get https://raw.githubusercontent.com/pascalw/kindle-dash/master/example/example.png
set -eu
# 拉取低频背景图,调用方负责传入输出路径。
output_path=${1:?"missing output path"}
background_url=${BACKGROUND_URL:-"https://shell.biboer.cn:20001/kindlebg.png"}
"$(dirname "$0")/../xh" -d -q -o "$output_path" get "$background_url"

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env sh
set -eu
DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
clock_face_path=${CLOCK_FACE_PATH:-"$DIR/../assets/clock-face.png"}
hour_assets_dir=${CLOCK_HOUR_ASSETS_DIR:-"$DIR/../assets/hour-hand"}
minute_assets_dir=${CLOCK_MINUTE_ASSETS_DIR:-"$DIR/../assets/minute-hand"}
clock_region_x=${CLOCK_REGION_X:-313}
clock_region_y=${CLOCK_REGION_Y:-0}
force_full_refresh=${1:-false}
eval "$("$DIR/clock-index.sh")"
hour_patch="$hour_assets_dir/$hour_index.png"
minute_patch="$minute_assets_dir/$minute_index.png"
if [ ! -f "$clock_face_path" ] || [ ! -f "$hour_patch" ] || [ ! -f "$minute_patch" ]; then
echo "Clock assets missing."
echo "Face: $clock_face_path"
echo "Hour: $hour_patch"
echo "Minute: $minute_patch"
exit 1
fi
if [ "$force_full_refresh" = true ]; then
/usr/sbin/eips -f -g "$clock_face_path" -x "$clock_region_x" -y "$clock_region_y"
else
/usr/sbin/eips -g "$clock_face_path" -x "$clock_region_x" -y "$clock_region_y"
fi
/usr/sbin/eips -g "$hour_patch" -x "$clock_region_x" -y "$clock_region_y"
/usr/sbin/eips -g "$minute_patch" -x "$clock_region_x" -y "$clock_region_y"

View File

@@ -0,0 +1,166 @@
#!/usr/bin/env swift
import AppKit
import Foundation
struct HandConfig {
let sourcePath: String
let outputDirectoryName: String
let frameCount: Int
let sourceWidth: CGFloat
let sourceHeight: CGFloat
let pivotX: CGFloat
let pivotY: CGFloat
let digits: Int
let angleStep: CGFloat
}
enum AssetError: Error, CustomStringConvertible {
case invalidImage(String)
case pngEncodingFailed(String)
var description: String {
switch self {
case let .invalidImage(path):
return "无法读取图片:\(path)"
case let .pngEncodingFailed(path):
return "无法编码 PNG\(path)"
}
}
}
let fileManager = FileManager.default
let workingDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath, isDirectory: true)
let repoRoot = CommandLine.arguments.count > 1
? URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
: workingDirectory
let outputRoot = CommandLine.arguments.count > 2
? URL(fileURLWithPath: CommandLine.arguments[2], isDirectory: true)
: repoRoot.appendingPathComponent("assets/kindle-clock", isDirectory: true)
let faceSourceURL = repoRoot.appendingPathComponent("assets/clock-face.png")
let targetSize = NSSize(width: 220, height: 220)
let faceSourceSize = NSSize(width: 431, height: 431)
let scale = targetSize.width / faceSourceSize.width
let handConfigs = [
HandConfig(
sourcePath: "assets/hour-hand.png",
outputDirectoryName: "hour-hand",
frameCount: 720,
sourceWidth: 32,
sourceHeight: 205,
pivotX: 13,
pivotY: 138,
digits: 3,
angleStep: 0.5
),
HandConfig(
sourcePath: "assets/minite-hand.png",
outputDirectoryName: "minute-hand",
frameCount: 60,
sourceWidth: 32,
sourceHeight: 288,
pivotX: 15,
pivotY: 203,
digits: 2,
angleStep: 6
),
]
func loadImage(at url: URL) throws -> NSImage {
guard let image = NSImage(contentsOf: url) else {
throw AssetError.invalidImage(url.path)
}
return image
}
func savePNG(_ image: NSImage, to url: URL) throws {
guard
let tiffRepresentation = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiffRepresentation),
let pngData = bitmap.representation(using: .png, properties: [:])
else {
throw AssetError.pngEncodingFailed(url.path)
}
try fileManager.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try pngData.write(to: url)
}
func renderImage(size: NSSize, draw: () -> Void) -> NSImage {
let image = NSImage(size: size)
image.lockFocusFlipped(true)
NSColor.clear.setFill()
NSBezierPath(rect: NSRect(origin: .zero, size: size)).fill()
draw()
image.unlockFocus()
return image
}
func renderFace() throws {
let faceImage = try loadImage(at: faceSourceURL)
let renderedFace = renderImage(size: targetSize) {
faceImage.draw(
in: NSRect(origin: .zero, size: targetSize),
from: NSRect(origin: .zero, size: faceImage.size),
operation: .sourceOver,
fraction: 1
)
}
try savePNG(renderedFace, to: outputRoot.appendingPathComponent("clock-face.png"))
}
func renderHandFrames(config: HandConfig) throws {
let sourceURL = repoRoot.appendingPathComponent(config.sourcePath)
let handImage = try loadImage(at: sourceURL)
let outputDirectory = outputRoot.appendingPathComponent(config.outputDirectoryName, isDirectory: true)
let scaledWidth = config.sourceWidth * scale
let scaledHeight = config.sourceHeight * scale
let scaledPivotX = config.pivotX * scale
let scaledPivotY = config.pivotY * scale
try fileManager.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
for frameIndex in 0..<config.frameCount {
let angle = CGFloat(frameIndex) * config.angleStep
let renderedFrame = renderImage(size: targetSize) {
guard let context = NSGraphicsContext.current?.cgContext else {
return
}
context.translateBy(x: targetSize.width / 2, y: targetSize.height / 2)
context.rotate(by: angle * .pi / 180)
context.translateBy(x: -scaledPivotX, y: -scaledPivotY)
handImage.draw(
in: NSRect(x: 0, y: 0, width: scaledWidth, height: scaledHeight),
from: NSRect(origin: .zero, size: handImage.size),
operation: .sourceOver,
fraction: 1
)
}
let filename = String(format: "%0\(config.digits)d.png", frameIndex)
try savePNG(renderedFrame, to: outputDirectory.appendingPathComponent(filename))
}
}
do {
try fileManager.createDirectory(at: outputRoot, withIntermediateDirectories: true)
try renderFace()
for config in handConfigs {
try renderHandFrames(config: config)
}
print("Generated Kindle clock assets at \(outputRoot.path)")
} catch {
fputs("\(error)\n", stderr)
exit(1)
}

View File

@@ -0,0 +1,14 @@
#!/usr/bin/env sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
TMP_DIR="${TMPDIR:-/tmp}/kindle-clock-assets"
KINDLE_TARGET=${1:-kindle}
rm -rf "$TMP_DIR"
/usr/bin/swift "$ROOT_DIR/scripts/generate-kindle-clock-assets.swift" "$ROOT_DIR" "$TMP_DIR"
ssh "$KINDLE_TARGET" 'mkdir -p /mnt/us/dashboard/assets/hour-hand /mnt/us/dashboard/assets/minute-hand'
rsync -av --no-o --no-g --delete "$TMP_DIR"/ "$KINDLE_TARGET":/mnt/us/dashboard/assets/
echo "Clock assets synced to $KINDLE_TARGET:/mnt/us/dashboard/assets"

View File

@@ -0,0 +1,71 @@
#!/usr/bin/env sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
KINDLE_TARGET=${1:-kindle}
CLOCK_REGION_JSON="$ROOT_DIR/calendar/dist/clock-region.json"
clock_region_value() {
key=$1
python3 - "$CLOCK_REGION_JSON" "$key" <<'PY'
import json
import pathlib
import sys
path = pathlib.Path(sys.argv[1])
key = sys.argv[2]
if not path.exists():
defaults = {"x": 262, "y": 55, "width": 220, "height": 220}
print(defaults[key])
raise SystemExit(0)
data = json.loads(path.read_text())
print(data[key])
PY
}
clock_x=$(clock_region_value x)
clock_y=$(clock_region_value y)
clock_width=$(clock_region_value width)
clock_height=$(clock_region_value height)
sh "$ROOT_DIR/scripts/sync-layered-clock-assets.sh" "$KINDLE_TARGET"
rsync -av --no-o --no-g \
"$ROOT_DIR/dash/src/dash.sh" \
"$KINDLE_TARGET":/mnt/us/dashboard/
rsync -av --no-o --no-g \
"$ROOT_DIR/dash/src/local/fetch-dashboard.sh" \
"$ROOT_DIR/dash/src/local/clock-index.sh" \
"$ROOT_DIR/dash/src/local/render-clock.sh" \
"$KINDLE_TARGET":/mnt/us/dashboard/local/
rsync -av --no-o --no-g \
"$ROOT_DIR/calendar/dist/kindlebg.png" \
"$KINDLE_TARGET":/mnt/us/dashboard/
ssh "$KINDLE_TARGET" "chmod +x /mnt/us/dashboard/dash.sh /mnt/us/dashboard/local/fetch-dashboard.sh /mnt/us/dashboard/local/clock-index.sh /mnt/us/dashboard/local/render-clock.sh"
ssh "$KINDLE_TARGET" "mkdir -p /mnt/us/dashboard/local/state"
ssh "$KINDLE_TARGET" "date '+%s' >/mnt/us/dashboard/local/state/background-updated-at"
ssh "$KINDLE_TARGET" "tmp=\$(mktemp) && awk \
-v x='$clock_x' \
-v y='$clock_y' \
-v w='$clock_width' \
-v h='$clock_height' \
'BEGIN { seen_x=0; seen_y=0; seen_w=0; seen_h=0 } \
/^export CLOCK_REGION_X=/ { print \"export CLOCK_REGION_X=\" x; seen_x=1; next } \
/^export CLOCK_REGION_Y=/ { print \"export CLOCK_REGION_Y=\" y; seen_y=1; next } \
/^export CLOCK_REGION_WIDTH=/ { print \"export CLOCK_REGION_WIDTH=\" w; seen_w=1; next } \
/^export CLOCK_REGION_HEIGHT=/ { print \"export CLOCK_REGION_HEIGHT=\" h; seen_h=1; next } \
{ print } \
END { \
if (!seen_x) print \"export CLOCK_REGION_X=\" x; \
if (!seen_y) print \"export CLOCK_REGION_Y=\" y; \
if (!seen_w) print \"export CLOCK_REGION_WIDTH=\" w; \
if (!seen_h) print \"export CLOCK_REGION_HEIGHT=\" h; \
}' /mnt/us/dashboard/local/env.sh >\"\$tmp\" && cat \"\$tmp\" >/mnt/us/dashboard/local/env.sh && rm -f \"\$tmp\""
echo "Layered clock runtime synced to $KINDLE_TARGET"