diff --git a/web/src/app.js b/web/src/app.js
index 7dfc89c..8b578ca 100644
--- a/web/src/app.js
+++ b/web/src/app.js
@@ -1,6 +1,11 @@
(() => {
const MAX_COMMITS = 2000;
+ const MANY_COMMITS = 200;
+ // OAuth App client ID for "Sign in with GitHub" (device flow, desktop app only).
+ const OAUTH_CLIENT_ID = '';
+
const $ = (id) => document.getElementById(id);
+ const desktop = window.__TAURI__ ?? null;
const store = {
get(key, fallback = null) {
try { return JSON.parse(localStorage.getItem(key)) ?? fallback; } catch { return fallback; }
@@ -19,11 +24,18 @@
user: null,
repos: [],
weeks: [],
+ total: null,
drawing: store.get('cc.drawing', {}), // date -> level (1-4)
- history: [],
+ undo: [],
+ redo: [],
level: 4,
- strength: store.get('cc.strength', null),
+ strength: store.get('cc.strength', null) ?? 4,
+ avoid: store.get('cc.avoid', true),
+ preview: false,
+ pan: false,
+ cursor: { col: 0, row: 0 },
busy: false,
+ device: null,
};
// ---------- i18n ----------
@@ -39,43 +51,42 @@
for (const el of document.querySelectorAll('[data-i18n-placeholder]')) el.placeholder = t(el.dataset.i18nPlaceholder);
for (const el of document.querySelectorAll('[data-i18n-title]')) el.title = t(el.dataset.i18nTitle);
for (const el of document.querySelectorAll('[data-i18n-aria-label]')) el.setAttribute('aria-label', t(el.dataset.i18nAriaLabel));
- const pastYear = $('year').querySelector('option[value=""]');
- if (pastYear) pastYear.textContent = t('year.past');
- const noRepo = $('repo').querySelector('option[value=""]');
- if (noRepo) noRepo.textContent = t('repo.none');
+ const past = $('range').querySelector('option[value=""]');
+ if (past) past.textContent = t('range.past');
+ const none = $('repo').querySelector('option[value=""]');
+ if (none) none.textContent = t('repo.none');
buildGraph();
+ renderAccount();
+ renderRepoHint();
renderLast();
}
// ---------- status ----------
function setStatus(text = '', kind = '') {
- const el = $('status');
- el.className = `status ${kind}`;
- el.textContent = text;
+ $('status').className = `status ${kind}`;
+ $('status').textContent = text;
}
function setStatusLink(text, href, label) {
setStatus(`${text} `, 'ok');
- const link = document.createElement('a');
- link.href = href;
- link.target = '_blank';
- link.rel = 'noopener';
- link.textContent = label;
+ const link = Object.assign(document.createElement('a'), { href, target: '_blank', rel: 'noopener', textContent: label });
$('status').append(link);
}
- function showError(error) {
- if (error?.status === 401) setStatus(t('error.token'), 'error');
- else if (error?.message === 'NO_PUSH') setStatus(t('error.noPush'), 'error');
- else setStatus(t('error.generic', { msg: error?.message ?? String(error) }), 'error');
+ function errorText(error) {
+ if (error?.status === 401) return t('error.token');
+ if (error?.status === 0) return t('error.network');
+ if (error?.message === 'NO_PUSH') return t('error.noPush');
+ return t('error.generic', { msg: error?.message ?? String(error) });
}
// ---------- dates & grid ----------
const pad = (n) => String(n).padStart(2, '0');
const isoLocal = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const today = () => isoLocal(new Date());
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
- // Empty past-year grid used before connecting.
+ // Empty past-year grid used before signing in.
function offlineWeeks() {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - now.getDay() - 52 * 7);
@@ -86,19 +97,25 @@
}));
}
- const paintable = (cell) => cell && cell.date <= today();
+ const paintable = (cell) => Boolean(cell) && cell.date <= today();
+ const cellAtPos = (col, row) => state.weeks[col]?.[row] ?? null;
+ const countFor = (level) => Math.max(1, Math.round((state.strength * level) / 4));
- function visibleDrawn() {
+ // Planned cells in the current view: {date, level, blocked}.
+ function planned() {
const out = [];
for (const week of state.weeks) {
for (const cell of week) {
- if (paintable(cell) && state.drawing[cell.date]) out.push([cell.date, state.drawing[cell.date]]);
+ const level = paintable(cell) ? state.drawing[cell.date] : 0;
+ if (level) out.push({ date: cell.date, level, blocked: state.avoid && cell.count > 0 });
}
}
- return out;
+ return out.sort((a, b) => a.date.localeCompare(b.date));
}
- const countFor = (level) => Math.max(1, Math.round((state.strength * level) / 4));
+ function planDates() {
+ return planned().filter((p) => !p.blocked).flatMap((p) => Array(countFor(p.level)).fill(p.date));
+ }
function autoStrength() {
const counts = state.weeks.flat().filter((c) => c?.count > 0).map((c) => c.count).sort((a, b) => a - b);
@@ -106,13 +123,26 @@
return Math.min(30, Math.max(4, p90));
}
+ // Estimated levels after sending, using quartiles like GitHub does.
+ function previewLevels() {
+ const added = new Map(planned().filter((p) => !p.blocked).map((p) => [p.date, countFor(p.level)]));
+ const totals = new Map();
+ for (const cell of state.weeks.flat()) if (cell) totals.set(cell.date, cell.count + (added.get(cell.date) ?? 0));
+ const nonZero = [...totals.values()].filter((n) => n > 0).sort((a, b) => a - b);
+ const q = (p) => nonZero[Math.floor((nonZero.length - 1) * p)] ?? 0;
+ const [q1, q2, q3] = [q(0.25), q(0.5), q(0.75)];
+ const levels = new Map();
+ for (const [date, n] of totals) levels.set(date, n === 0 ? 0 : n <= q1 ? 1 : n <= q2 ? 2 : n <= q3 ? 3 : 4);
+ return levels;
+ }
+
function buildGraph() {
const graph = $('graph');
graph.replaceChildren();
graph.style.setProperty('--cols', state.weeks.length);
-
- const monthFmt = new Intl.DateTimeFormat(state.lang === 'zh' ? 'zh-CN' : 'en', { month: 'short' });
- const dayFmt = new Intl.DateTimeFormat(state.lang === 'zh' ? 'zh-CN' : 'en', { weekday: 'short' });
+ const locale = state.lang === 'zh' ? 'zh-CN' : 'en';
+ const monthFmt = new Intl.DateTimeFormat(locale, { month: 'short' });
+ const dayFmt = new Intl.DateTimeFormat(locale, { weekday: 'short' });
graph.append(Object.assign(document.createElement('span'), { className: 'corner' }));
const months = state.weeks.map((week) => week.find(Boolean)?.date.slice(0, 7));
@@ -138,122 +168,250 @@
if (row % 2 === 1) day.textContent = dayFmt.format(new Date(2024, 0, 7 + row));
graph.append(day);
state.weeks.forEach((week, col) => {
- const cell = week[row];
const el = document.createElement('i');
- el.className = 'cell';
el.style.gridColumn = col + 2;
el.style.gridRow = row + 2;
el.dataset.col = col;
el.dataset.row = row;
- if (cell) {
- el.dataset.date = cell.date;
- el.title = cell.count ? `${cell.date} · ${cell.count}` : cell.date;
- }
graph.append(el);
});
}
renderCells();
- // On narrow screens start at the most recent weeks, like GitHub does.
+ // Start at the most recent weeks on narrow screens, like GitHub does.
graph.parentElement.scrollLeft = graph.parentElement.scrollWidth;
}
function renderCells() {
- for (const el of $('graph').querySelectorAll('.cell')) {
- const cell = state.weeks[el.dataset.col][el.dataset.row];
+ const preview = state.preview ? previewLevels() : null;
+ for (const el of $('graph').querySelectorAll('i[data-col]')) {
+ const cell = cellAtPos(el.dataset.col, el.dataset.row);
let cls = 'cell';
- if (!paintable(cell)) cls += ' off';
- else if (state.drawing[cell.date]) cls += ` l${state.drawing[cell.date]} drawn`;
- else if (cell.level) cls += ` l${cell.level} existing`;
- else cls += ' l0';
+ let title = '';
+ if (!paintable(cell)) {
+ cls += ' off';
+ } else {
+ const drawn = state.drawing[cell.date];
+ title = cell.count ? `${cell.date} · ${cell.count}` : cell.date;
+ if (preview) cls += ` l${preview.get(cell.date) ?? 0}`;
+ else if (drawn) cls += ` l${drawn} drawn${state.avoid && cell.count ? ' blocked' : ''}`;
+ else cls += ` l${cell.level} existing`;
+ if (drawn) title += ` → +${countFor(drawn)}`;
+ }
+ if (Number(el.dataset.col) === state.cursor.col && Number(el.dataset.row) === state.cursor.row) cls += ' cursor';
el.className = cls;
+ el.title = title;
}
renderSummary();
}
function renderSummary() {
- const drawn = visibleDrawn();
- const commits = drawn.reduce((sum, [, level]) => sum + countFor(level), 0);
- $('summary-commits').textContent = t('summary.commits', { n: commits });
- $('summary-pixels').textContent = t('summary.pixels', { n: drawn.length });
+ const items = planned();
+ const active = items.filter((p) => !p.blocked);
+ const commits = active.reduce((sum, p) => sum + countFor(p.level), 0);
+ $('sum-commits').textContent = commits;
+ $('sum-days').textContent = active.length;
+ $('sum-range').textContent = active.length ? `${active[0].date} – ${active.at(-1).date}` : '—';
+ const skipped = items.length - active.length;
+ $('sum-skipped').hidden = skipped === 0;
+ $('sum-skipped').textContent = t('summary.skipped', { n: skipped });
+ $('legend-blocked').hidden = !state.avoid;
$('paint').disabled = state.busy || commits === 0;
+ $('undo').disabled = state.busy || !state.undo.length;
+ $('redo').disabled = state.busy || !state.redo.length;
+ for (const brush of document.querySelectorAll('.brush[data-level]')) {
+ const level = Number(brush.dataset.level);
+ if (level > 0) brush.querySelector('small').textContent = t('tool.commits', { n: countFor(level) });
+ }
+ renderSteps();
}
- // ---------- drawing ----------
- function snapshot() {
- state.history.push(JSON.stringify(state.drawing));
- if (state.history.length > 100) state.history.shift();
+ function renderSteps() {
+ $('step-connect').classList.toggle('done', Boolean(state.user));
+ $('step-repo').classList.toggle('done', Boolean($('repo').value));
+ $('step-draw').classList.toggle('done', planned().some((p) => !p.blocked));
+ }
+
+ // ---------- history ----------
+ function remember() {
+ state.undo.push(JSON.stringify(state.drawing));
+ if (state.undo.length > 100) state.undo.shift();
+ state.redo = [];
}
- function commit() {
+ function save() {
store.set('cc.drawing', state.drawing);
renderCells();
}
+ function undo() {
+ if (!state.undo.length) return;
+ state.redo.push(JSON.stringify(state.drawing));
+ state.drawing = JSON.parse(state.undo.pop());
+ save();
+ }
+
+ function redo() {
+ if (!state.redo.length) return;
+ state.undo.push(JSON.stringify(state.drawing));
+ state.drawing = JSON.parse(state.redo.pop());
+ save();
+ }
+
+ // ---------- drawing ----------
function setCell(cell, level) {
if (!paintable(cell)) return;
if (level > 0) state.drawing[cell.date] = level;
else delete state.drawing[cell.date];
}
- function cellAt(x, y) {
+ // Clicking a square that already has the selected shade erases it.
+ const strokeLevel = (cell, erase) => (erase || (state.level > 0 && state.drawing[cell.date] === state.level) ? 0 : state.level);
+
+ function leavePreview() {
+ if (!state.preview) return;
+ state.preview = false;
+ renderMode();
+ }
+
+ function cellFromPoint(x, y) {
const el = document.elementFromPoint(x, y);
- if (!el?.classList.contains('cell') || !el.closest('#graph')) return null;
- return state.weeks[el.dataset.col]?.[el.dataset.row] ?? null;
+ if (!el?.dataset?.col || !el.closest('#graph')) return null;
+ return { col: Number(el.dataset.col), row: Number(el.dataset.row), cell: cellAtPos(el.dataset.col, el.dataset.row) };
}
let stroke = null;
$('graph').addEventListener('pointerdown', (event) => {
- const cell = cellAt(event.clientX, event.clientY);
- if (!paintable(cell) || state.busy) return;
+ if (state.busy || state.pan) return;
+ const hit = cellFromPoint(event.clientX, event.clientY);
+ if (!hit || !paintable(hit.cell)) return;
event.preventDefault();
+ leavePreview();
$('graph').setPointerCapture(event.pointerId);
- snapshot();
- const current = state.drawing[cell.date] ?? 0;
- // Right-click, or clicking a square that already has this shade, erases.
- const level = event.button === 2 || (current === state.level && state.level > 0) ? 0 : state.level;
- stroke = { level };
- setCell(cell, level);
+ remember();
+ stroke = { level: strokeLevel(hit.cell, event.button === 2) };
+ state.cursor = { col: hit.col, row: hit.row };
+ setCell(hit.cell, stroke.level);
renderCells();
});
$('graph').addEventListener('pointermove', (event) => {
if (!stroke) return;
- const cell = cellAt(event.clientX, event.clientY);
- if (cell) {
- setCell(cell, stroke.level);
+ const hit = cellFromPoint(event.clientX, event.clientY);
+ if (hit?.cell) {
+ setCell(hit.cell, stroke.level);
renderCells();
}
});
const endStroke = () => {
if (!stroke) return;
stroke = null;
- commit();
+ save();
};
$('graph').addEventListener('pointerup', endStroke);
$('graph').addEventListener('pointercancel', endStroke);
$('graph').addEventListener('contextmenu', (event) => event.preventDefault());
+ // Keyboard drawing: arrows move, Space/Enter paints, Home/End jump.
+ $('graph').addEventListener('keydown', (event) => {
+ const { col, row } = state.cursor;
+ const last = state.weeks.length - 1;
+ const moves = {
+ ArrowLeft: [Math.max(0, col - 1), row],
+ ArrowRight: [Math.min(last, col + 1), row],
+ ArrowUp: [col, Math.max(0, row - 1)],
+ ArrowDown: [col, Math.min(6, row + 1)],
+ Home: [0, row],
+ End: [last, row],
+ PageUp: [Math.max(0, col - 4), row],
+ PageDown: [Math.min(last, col + 4), row],
+ };
+ if (moves[event.key]) {
+ event.preventDefault();
+ state.cursor = { col: moves[event.key][0], row: moves[event.key][1] };
+ renderCells();
+ } else if (event.key === ' ' || event.key === 'Enter') {
+ event.preventDefault();
+ const cell = cellAtPos(col, row);
+ if (!paintable(cell) || state.busy) return;
+ leavePreview();
+ remember();
+ setCell(cell, strokeLevel(cell, false));
+ save();
+ }
+ });
+
function selectLevel(level) {
state.level = level;
- for (const button of document.querySelectorAll('.swatch')) {
- button.setAttribute('aria-checked', String(Number(button.dataset.level) === level));
+ for (const brush of document.querySelectorAll('.brush')) {
+ brush.setAttribute('aria-checked', String(Number(brush.dataset.level) === level));
}
}
- for (const button of document.querySelectorAll('.swatch')) {
- button.addEventListener('click', () => selectLevel(Number(button.dataset.level)));
+ for (const brush of document.querySelectorAll('.brush')) {
+ brush.addEventListener('click', () => selectLevel(Number(brush.dataset.level)));
+ }
+
+ // Paints [col, row] pixels starting at the column that centers them.
+ function stamp(pixels, width, level) {
+ const start = Math.max(0, Math.floor((state.weeks.length - width) / 2));
+ for (const [col, row, own] of pixels) setCell(cellAtPos(start + col, row), own ?? level);
+ }
+
+ function clearView() {
+ for (const week of state.weeks) for (const cell of week) if (cell) delete state.drawing[cell.date];
}
$('text-form').addEventListener('submit', (event) => {
event.preventDefault();
const { pixels, width } = window.CC_renderText($('text').value);
if (!pixels.length) return;
- snapshot();
- const start = Math.max(0, Math.floor((state.weeks.length - width) / 2));
- for (const [col, row] of pixels) setCell(state.weeks[start + col]?.[row], state.level || 4);
- commit();
+ leavePreview();
+ remember();
+ stamp(pixels, width, state.level || 4);
+ save();
});
+ const TEMPLATES = {
+ heart: () => window.CC_renderText('♥'),
+ hello: () => window.CC_renderText('HELLO'),
+ wave() {
+ const width = state.weeks.length;
+ const pixels = [];
+ for (let col = 0; col < width; col += 1) {
+ const y = 3 - 2.6 * Math.sin((col / 12) * 2 * Math.PI);
+ for (let row = 0; row < 7; row += 1) {
+ const d = Math.abs(row - y);
+ if (d < 0.6) pixels.push([col, row, 4]);
+ else if (d < 1.4) pixels.push([col, row, 2]);
+ }
+ }
+ return { pixels, width };
+ },
+ stars() {
+ const width = state.weeks.length;
+ const pixels = [];
+ for (let col = 3, i = 0; col < width - 1; col += 7, i += 1) {
+ const row = i % 2 ? 4 : 2;
+ pixels.push([col, row, 4], [col - 1, row, 2], [col + 1, row, 2], [col, row - 1, 2], [col, row + 1, 2]);
+ pixels.push([col + 3, i % 2 ? 1 : 5, 1]);
+ }
+ return { pixels, width };
+ },
+ };
+
+ for (const button of document.querySelectorAll('[data-template]')) {
+ button.addEventListener('click', async () => {
+ if (planned().length && !(await ask(t('template.label'), t('template.replace'), t('confirm.ok')))) return;
+ const { pixels, width } = TEMPLATES[button.dataset.template]();
+ leavePreview();
+ remember();
+ clearView();
+ stamp(pixels, width, state.level || 4);
+ save();
+ });
+ }
+
function shift(delta) {
- snapshot();
+ leavePreview();
+ remember();
const moved = [];
state.weeks.forEach((week, col) => week.forEach((cell, row) => {
if (paintable(cell) && state.drawing[cell.date]) {
@@ -261,30 +419,58 @@
delete state.drawing[cell.date];
}
}));
- for (const [col, row, level] of moved) setCell(state.weeks[col]?.[row], level);
- commit();
+ for (const [col, row, level] of moved) setCell(cellAtPos(col, row), level);
+ save();
}
$('shift-left').addEventListener('click', () => shift(-1));
$('shift-right').addEventListener('click', () => shift(1));
+ $('undo').addEventListener('click', undo);
+ $('redo').addEventListener('click', redo);
+ $('clear').addEventListener('click', () => {
+ leavePreview();
+ remember();
+ clearView();
+ save();
+ });
- function undo() {
- if (!state.history.length) return;
- state.drawing = JSON.parse(state.history.pop());
- commit();
+ $('zoom').addEventListener('click', () => {
+ const on = !$('graph').classList.contains('zoomed');
+ $('graph').classList.toggle('zoomed', on);
+ $('zoom').setAttribute('aria-pressed', String(on));
+ store.set('cc.zoom', on);
+ });
+ $('pan').addEventListener('click', () => {
+ state.pan = !state.pan;
+ $('graph').classList.toggle('panning', state.pan);
+ $('pan').setAttribute('aria-pressed', String(state.pan));
+ });
+
+ function renderMode() {
+ $('mode-edit').setAttribute('aria-pressed', String(!state.preview));
+ $('mode-preview').setAttribute('aria-pressed', String(state.preview));
+ $('legend-edit').hidden = state.preview;
+ $('legend-preview').hidden = !state.preview;
+ renderCells();
}
- $('undo').addEventListener('click', undo);
+ $('mode-edit').addEventListener('click', () => { state.preview = false; renderMode(); });
+ $('mode-preview').addEventListener('click', () => { state.preview = true; renderMode(); });
- $('clear').addEventListener('click', () => {
- snapshot();
- for (const week of state.weeks) for (const cell of week) if (cell) delete state.drawing[cell.date];
- commit();
+ $('avoid').addEventListener('change', () => {
+ state.avoid = $('avoid').checked;
+ store.set('cc.avoid', state.avoid);
+ renderCells();
});
document.addEventListener('keydown', (event) => {
- if (event.target.closest('input, select, textarea')) return;
- if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') {
+ if (event.target.closest('input, select, textarea, dialog')) return;
+ const key = event.key.toLowerCase();
+ if ((event.ctrlKey || event.metaKey) && key === 'z') {
+ event.preventDefault();
+ if (event.shiftKey) redo();
+ else undo();
+ } else if ((event.ctrlKey || event.metaKey) && key === 'y') {
event.preventDefault();
- undo();
+ redo();
} else if (/^[0-4]$/.test(event.key)) {
selectLevel(Number(event.key));
}
@@ -295,11 +481,175 @@
if (value >= 1 && value <= 50) {
state.strength = value;
store.set('cc.strength', value);
- renderSummary();
+ renderCells();
+ }
+ });
+
+ // ---------- dialog ----------
+ // In-page confirmation; resolves to true when confirmed.
+ function ask(title, body, okLabel, { details = [], check = '', note = '' } = {}) {
+ const dialog = $('confirm');
+ $('confirm-title').textContent = title;
+ $('confirm-body').textContent = body;
+ $('confirm-details').replaceChildren(...details.flatMap(([k, v]) => [
+ Object.assign(document.createElement('dt'), { textContent: k }),
+ Object.assign(document.createElement('dd'), { textContent: v }),
+ ]));
+ $('confirm-details').hidden = !details.length;
+ $('confirm-check-wrap').hidden = !check;
+ $('confirm-check').checked = false;
+ $('confirm-check-text').textContent = check;
+ $('confirm-note').textContent = note;
+ $('confirm-note').hidden = !note;
+ $('confirm-ok').textContent = okLabel;
+ $('confirm-ok').disabled = Boolean(check);
+ dialog.returnValue = '';
+ dialog.showModal();
+ return new Promise((resolve) => {
+ dialog.addEventListener('close', () => resolve(dialog.returnValue === 'ok'), { once: true });
+ });
+ }
+ $('confirm-check').addEventListener('change', () => { $('confirm-ok').disabled = !$('confirm-check').checked; });
+
+ // ---------- files ----------
+ async function saveFile(name, text) {
+ if (desktop) {
+ const path = await desktop.dialog.save({ defaultPath: name });
+ if (!path) return false;
+ await desktop.core.invoke('write_text', { path, contents: text });
+ return true;
+ }
+ const url = URL.createObjectURL(new Blob([text], { type: 'text/plain' }));
+ const link = Object.assign(document.createElement('a'), { href: url, download: name });
+ document.body.append(link);
+ link.click();
+ link.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ return true;
+ }
+
+ $('design-save').addEventListener('click', async () => {
+ const design = { kind: 'commit-canvas-design', version: 2, strength: state.strength, drawing: state.drawing };
+ try {
+ if (await saveFile('commit-canvas-design.json', `${JSON.stringify(design, null, 2)}\n`)) setStatus(t('design.saved'), 'ok');
+ } catch (error) {
+ setStatus(errorText(error), 'error');
}
});
- // ---------- GitHub ----------
+ $('design-open').addEventListener('click', () => $('design-file').click());
+ $('design-file').addEventListener('change', async () => {
+ const file = $('design-file').files[0];
+ $('design-file').value = '';
+ if (!file) return;
+ const drawing = parseDesign(await file.text().catch(() => ''));
+ if (!drawing) {
+ setStatus(t('design.invalid'), 'error');
+ return;
+ }
+ leavePreview();
+ remember();
+ state.drawing = drawing.drawing;
+ if (drawing.strength) {
+ state.strength = drawing.strength;
+ $('strength').value = drawing.strength;
+ store.set('cc.strength', drawing.strength);
+ }
+ save();
+ setStatus(t('design.loaded'), 'ok');
+ });
+
+ // Accepts this version's designs and the original 53×7 design files.
+ function parseDesign(text) {
+ let data;
+ try { data = JSON.parse(text); } catch { return null; }
+ if (!data || typeof data !== 'object') return null;
+ const drawing = {};
+ const okLevel = (l) => Number.isInteger(l) && l >= 1 && l <= 4;
+ if (data.kind === 'commit-canvas-design' && data.drawing && typeof data.drawing === 'object') {
+ for (const [date, level] of Object.entries(data.drawing)) {
+ if (DATE_RE.test(date) && okLevel(level)) drawing[date] = level;
+ }
+ const strength = Number.isInteger(data.strength) && data.strength >= 1 && data.strength <= 50 ? data.strength : null;
+ return { drawing, strength };
+ }
+ if (data.version === 1 && DATE_RE.test(data.endDate ?? '') && Array.isArray(data.levels) && data.levels.length === 371) {
+ const [y, m, d] = data.endDate.split('-').map(Number);
+ const end = new Date(Date.UTC(y, m - 1, d));
+ const first = new Date(end);
+ first.setUTCDate(end.getUTCDate() - end.getUTCDay() - 52 * 7);
+ data.levels.forEach((level, index) => {
+ if (!okLevel(level)) return;
+ const day = new Date(first);
+ day.setUTCDate(first.getUTCDate() + index);
+ drawing[day.toISOString().slice(0, 10)] = level;
+ });
+ return { drawing, strength: null };
+ }
+ return null;
+ }
+
+ function scriptFor(kind, email) {
+ const dates = planDates();
+ const perDay = new Map();
+ const commits = dates.map((date) => {
+ const n = (perDay.get(date) ?? 0) + 1;
+ perDay.set(date, n);
+ return [window.CC_GitHub.noonOn(date, n), `Commit Canvas ${date} #${n}`];
+ });
+ if (kind === 'bash') {
+ return [
+ '#!/usr/bin/env bash',
+ `# Commit Canvas: creates ${commits.length} empty commits in the current Git repository.`,
+ 'set -euo pipefail',
+ 'git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { echo "Run this inside a Git repository folder."; exit 1; }',
+ `read -r -p "Create ${commits.length} empty commits in $(pwd)? [y/N] " answer`,
+ '[[ "$answer" == [yY]* ]] || exit 1',
+ `export GIT_AUTHOR_EMAIL='${email}' GIT_COMMITTER_EMAIL='${email}'`,
+ 'c() { GIT_AUTHOR_DATE="$1" GIT_COMMITTER_DATE="$1" git commit --allow-empty -q -m "$2"; }',
+ ...commits.map(([date, message]) => `c '${date}' '${message}'`),
+ 'echo "Done. Now run: git push"',
+ '',
+ ].join('\n');
+ }
+ return [
+ `# Commit Canvas: creates ${commits.length} empty commits in the current Git repository.`,
+ "$ErrorActionPreference = 'Stop'",
+ 'git rev-parse --is-inside-work-tree *> $null',
+ "if ($LASTEXITCODE -ne 0) { throw 'Run this inside a Git repository folder.' }",
+ `$answer = Read-Host "Create ${commits.length} empty commits in $(Get-Location)? [y/N]"`,
+ "if ($answer -notmatch '^[yY]') { exit 1 }",
+ `$env:GIT_AUTHOR_EMAIL = '${email}'; $env:GIT_COMMITTER_EMAIL = '${email}'`,
+ 'function c($d, $m) { $env:GIT_AUTHOR_DATE = $d; $env:GIT_COMMITTER_DATE = $d; git commit --allow-empty -q -m $m; if ($LASTEXITCODE -ne 0) { throw "git commit failed" } }',
+ ...commits.map(([date, message]) => `c '${date}' '${message}'`),
+ "Remove-Item Env:GIT_AUTHOR_DATE, Env:GIT_COMMITTER_DATE, Env:GIT_AUTHOR_EMAIL, Env:GIT_COMMITTER_EMAIL",
+ "Write-Host 'Done. Now run: git push'",
+ '',
+ ].join('\r\n');
+ }
+
+ async function exportScript(kind) {
+ const email = $('script-email').value.trim();
+ if (!/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(email)) {
+ setStatus(t('script.badEmail'), 'error');
+ $('script-email').focus();
+ return;
+ }
+ if (!planDates().length) {
+ setStatus(t('paint.empty'), 'error');
+ return;
+ }
+ try {
+ const name = kind === 'bash' ? 'commit-canvas.sh' : 'commit-canvas.ps1';
+ if (await saveFile(name, scriptFor(kind, email))) setStatus(t('script.saved'), 'ok');
+ } catch (error) {
+ setStatus(errorText(error), 'error');
+ }
+ }
+ $('script-bash').addEventListener('click', () => exportScript('bash'));
+ $('script-ps').addEventListener('click', () => exportScript('powershell'));
+
+ // ---------- sign in ----------
function savedToken() {
try {
const remembered = localStorage.getItem('cc.token');
@@ -309,59 +659,152 @@
}
}
- async function connect(token, remember) {
+ function forgetToken() {
+ try {
+ localStorage.removeItem('cc.token');
+ sessionStorage.removeItem('cc.token');
+ } catch { /* storage unavailable */ }
+ }
+
+ async function connect(token, rememberToken) {
state.api = window.CC_GitHub.create(token, {
onWait: (seconds) => setStatus(t('progress.wait', { s: seconds })),
});
- setStatus('…');
try {
state.user = await state.api.user();
} catch (error) {
state.api = null;
if (error.status === 401) forgetToken();
- showError(error);
+ setConnectError(errorText(error));
return;
}
try {
- if (remember) localStorage.setItem('cc.token', token);
+ if (rememberToken) localStorage.setItem('cc.token', token);
else sessionStorage.setItem('cc.token', token);
} catch { /* storage unavailable */ }
$('token').value = '';
- $('connect').hidden = true;
- $('setup').hidden = false;
- $('account').hidden = false;
- $('avatar').src = state.user.avatar;
- $('login').textContent = state.user.login;
- $('login').href = state.user.url;
- setStatus();
+ setConnectError('');
+ if (!$('script-email').value) $('script-email').value = state.user.email;
+ renderAccount();
await Promise.all([loadRepos(), loadCalendar()]);
}
- function forgetToken() {
- try {
- localStorage.removeItem('cc.token');
- sessionStorage.removeItem('cc.token');
- } catch { /* storage unavailable */ }
+ function setConnectError(text) {
+ let el = $('connect-error');
+ if (!el) {
+ el = Object.assign(document.createElement('p'), { id: 'connect-error', className: 'status error' });
+ $('connect-body').append(el);
+ }
+ el.textContent = text;
+ }
+
+ function renderAccount() {
+ const user = state.user;
+ $('account').hidden = !user;
+ $('connect-body').hidden = Boolean(user);
+ $('connect-done').hidden = !user;
+ $('repo').disabled = !user;
+ $('repo-new').disabled = !user;
+ $('refresh').hidden = !user;
+ if (!user) {
+ $('total').textContent = '';
+ return;
+ }
+ $('avatar').src = user.avatar;
+ $('login').textContent = user.login;
+ $('login').href = user.url;
+ $('connect-done').textContent = `✓ ${user.name} (@${user.login})`;
+ $('total').textContent = state.total === null ? '' : t('account.total', { n: state.total });
+ renderSteps();
}
function signOut() {
forgetToken();
- state.api = null;
- state.user = null;
- $('connect').hidden = false;
- $('setup').hidden = true;
- $('account').hidden = true;
- $('last').hidden = true;
+ stopDevice();
+ Object.assign(state, { api: null, user: null, repos: [], total: null });
+ $('repo').replaceChildren(new Option(t('repo.none'), ''));
+ $('range').replaceChildren(new Option(t('range.past'), ''));
+ $('new-repo').hidden = true;
state.weeks = offlineWeeks();
+ renderAccount();
+ renderRepoHint();
+ renderLast();
buildGraph();
setStatus();
}
+ $('connect-form').addEventListener('submit', (event) => {
+ event.preventDefault();
+ const token = $('token').value.trim();
+ if (token) connect(token, $('remember').checked);
+ });
+ $('signout').addEventListener('click', signOut);
+
+ // Desktop app: reuse an existing GitHub CLI login.
+ $('gh-login').addEventListener('click', async () => {
+ try {
+ const token = await desktop.core.invoke('gh_token');
+ await connect(token, true);
+ } catch {
+ setConnectError(t('gh.missing'));
+ }
+ });
+
+ // Desktop app: OAuth device flow ("Sign in with GitHub").
+ function stopDevice() {
+ if (state.device) clearTimeout(state.device.timer);
+ state.device = null;
+ $('device').hidden = true;
+ $('quick-login').hidden = !desktop;
+ }
+
+ $('device-login').addEventListener('click', async () => {
+ setConnectError('');
+ let start;
+ try {
+ start = JSON.parse(await desktop.core.invoke('device_start', { clientId: OAUTH_CLIENT_ID }));
+ if (!start.device_code) throw new Error(start.error_description || start.error || 'device flow failed');
+ } catch (error) {
+ setConnectError(errorText(error));
+ return;
+ }
+ state.device = { ...start, interval: start.interval || 5 };
+ $('device-code').textContent = start.user_code;
+ $('device').hidden = false;
+ $('quick-login').hidden = true;
+ navigator.clipboard?.writeText(start.user_code).catch(() => {});
+ openExternal(start.verification_uri);
+ const poll = async () => {
+ if (!state.device) return;
+ let result;
+ try {
+ result = JSON.parse(await desktop.core.invoke('device_poll', { clientId: OAUTH_CLIENT_ID, deviceCode: start.device_code }));
+ } catch {
+ result = { error: 'authorization_pending' };
+ }
+ if (!state.device) return;
+ if (result.access_token) {
+ stopDevice();
+ await connect(result.access_token, true);
+ } else if (result.error === 'authorization_pending' || result.error === 'slow_down') {
+ if (result.error === 'slow_down') state.device.interval += 5;
+ state.device.timer = setTimeout(poll, state.device.interval * 1000);
+ } else {
+ stopDevice();
+ setConnectError(t(result.error === 'access_denied' ? 'device.denied' : 'device.expired'));
+ }
+ };
+ state.device.timer = setTimeout(poll, state.device.interval * 1000);
+ });
+ $('device-open').addEventListener('click', () => state.device && openExternal(state.device.verification_uri));
+ $('device-cancel').addEventListener('click', stopDevice);
+
+ // ---------- repositories ----------
async function loadRepos(created) {
try {
state.repos = await state.api.repos();
} catch (error) {
- showError(error);
+ setStatus(errorText(error), 'error');
return;
}
// A just-created repository may not be listed yet.
@@ -377,47 +820,35 @@
}
function onRepoChange() {
- const repo = state.repos.find((r) => r.fullName === $('repo').value);
- store.set('cc.repo', repo?.fullName ?? null);
- $('private-hint').hidden = !repo?.private;
+ store.set('cc.repo', $('repo').value || null);
+ renderRepoHint();
renderLast();
+ renderSteps();
}
- async function loadCalendar() {
- const year = $('year').value;
- try {
- const { weeks, years } = await state.api.calendar(year ? Number(year) : null);
- state.weeks = weeks;
- const el = $('year');
- const options = ['', ...years.map(String)];
- if (el.options.length !== options.length) {
- el.replaceChildren(...options.map((value) => new Option(value || t('year.past'), value)));
- el.value = year;
- }
- } catch (error) {
- showError(error);
- return;
- }
- if (store.get('cc.strength') === null) {
- state.strength = autoStrength();
- $('strength').value = state.strength;
- }
- buildGraph();
+ function renderRepoHint() {
+ const repo = state.repos.find((r) => r.fullName === $('repo').value);
+ let hint = '';
+ if (state.user && !repo) hint = t('repo.suggest');
+ else if (repo?.private) hint = t('repo.privateHint');
+ else if (repo && !/canvas|art|draw|paint|graffiti/i.test(repo.fullName)) hint = t('repo.realHint');
+ $('repo-hint').textContent = hint;
+ $('repo-hint').hidden = !hint;
+ $('repo-new').classList.toggle('primary', Boolean(state.user && !repo));
}
- $('connect-form').addEventListener('submit', (event) => {
- event.preventDefault();
- const token = $('token').value.trim();
- if (token) connect(token, $('remember').checked);
- });
- $('signout').addEventListener('click', signOut);
- $('repo').addEventListener('change', onRepoChange);
- $('year').addEventListener('change', loadCalendar);
- $('refresh').addEventListener('click', loadCalendar);
+ function suggestName() {
+ const taken = new Set(state.repos.map((r) => r.fullName.split('/')[1].toLowerCase()));
+ let name = 'commit-canvas-art';
+ for (let i = 2; taken.has(name); i += 1) name = `commit-canvas-art-${i}`;
+ return name;
+ }
+ $('repo').addEventListener('change', onRepoChange);
$('repo-new').addEventListener('click', () => {
$('new-repo').hidden = false;
- $('new-repo-name').focus();
+ $('new-repo-name').value = suggestName();
+ $('new-repo-name').select();
});
$('new-repo-cancel').addEventListener('click', () => { $('new-repo').hidden = true; });
$('new-repo').addEventListener('submit', async (event) => {
@@ -425,19 +856,46 @@
try {
const repo = await state.api.createRepo($('new-repo-name').value.trim(), $('new-repo-private').checked);
$('new-repo').hidden = true;
- $('new-repo-name').value = '';
await loadRepos(repo);
} catch (error) {
- showError(error);
+ setStatus(errorText(error), 'error');
}
});
- // ---------- paint ----------
+ // ---------- contributions ----------
+ async function loadCalendar() {
+ const year = $('range').value;
+ try {
+ const { weeks, years, total } = await state.api.calendar(year ? Number(year) : null);
+ state.weeks = weeks;
+ state.total = total;
+ const options = ['', ...years.map(String)];
+ if ($('range').options.length !== options.length) {
+ $('range').replaceChildren(...options.map((value) => new Option(value || t('range.past'), value)));
+ $('range').value = year;
+ }
+ } catch (error) {
+ setStatus(errorText(error), 'error');
+ return;
+ }
+ if (store.get('cc.strength') === null) {
+ state.strength = autoStrength();
+ $('strength').value = state.strength;
+ }
+ state.cursor = { col: state.weeks.length - 1, row: 0 };
+ renderAccount();
+ buildGraph();
+ }
+ $('range').addEventListener('change', loadCalendar);
+ $('refresh').addEventListener('click', loadCalendar);
+
+ // ---------- send ----------
function setBusy(busy) {
state.busy = busy;
- for (const el of document.querySelectorAll('button, input, select')) {
- if (!el.closest('.top')) el.disabled = busy;
+ for (const el of document.querySelectorAll('main button, main input, main select')) {
+ if (!el.closest('#step-connect')) el.disabled = busy;
}
+ if (!busy) renderAccount();
renderSummary();
}
@@ -446,41 +904,34 @@
$('progress').firstElementChild.style.width = total ? `${(100 * done) / total}%` : '0';
}
- // In-page confirmation dialog; resolves to true when confirmed.
- function ask(title, body, okLabel, note = '') {
- const dialog = $('confirm');
- $('confirm-title').textContent = title;
- $('confirm-body').textContent = body;
- $('confirm-note').textContent = note;
- $('confirm-note').hidden = !note;
- $('confirm-ok').textContent = okLabel;
- dialog.returnValue = '';
- dialog.showModal();
- return new Promise((resolve) => {
- dialog.addEventListener('close', () => resolve(dialog.returnValue === 'ok'), { once: true });
- });
- }
-
$('paint').addEventListener('click', async () => {
const repo = $('repo').value;
if (!state.api || !repo) {
setStatus(t('paint.needConnect'), 'error');
return;
}
- const drawn = visibleDrawn().sort(([a], [b]) => a.localeCompare(b));
- const dates = drawn.flatMap(([date, level]) => Array(countFor(level)).fill(date));
+ const items = planned().filter((p) => !p.blocked);
+ const dates = planDates();
+ if (!dates.length) {
+ setStatus(t('paint.empty'), 'error');
+ return;
+ }
if (dates.length > MAX_COMMITS) {
setStatus(t('error.tooMany', { n: dates.length, max: MAX_COMMITS }), 'error');
return;
}
-
const branch = state.repos.find((r) => r.fullName === repo)?.branch ?? 'main';
- const ok = await ask(
- t('confirm.title'),
- t('confirm.body', { commits: dates.length, days: drawn.length, repo, branch }),
- t('confirm.ok'),
- t('confirm.note'),
- );
+ const ok = await ask(t('confirm.title'), t('confirm.body'), t('confirm.ok'), {
+ details: [
+ [t('confirm.account'), state.user.login],
+ [t('confirm.repo'), repo],
+ [t('confirm.branch'), branch],
+ [t('confirm.commits'), String(dates.length)],
+ [t('confirm.dates'), `${items[0].date} – ${items.at(-1).date}`],
+ ],
+ check: dates.length >= MANY_COMMITS ? t('confirm.many') : '',
+ note: t('confirm.note'),
+ });
if (!ok) return;
setBusy(true);
@@ -491,14 +942,14 @@
progress(done, total);
setStatus(phase === 'push' ? t('progress.push') : t('progress.commits', { done, total }));
});
- snapshot();
- const painted = Object.fromEntries(drawn);
- for (const [date] of drawn) delete state.drawing[date];
+ remember();
+ const sent = Object.fromEntries(items.map((p) => [p.date, p.level]));
+ for (const p of items) delete state.drawing[p.date];
store.set('cc.drawing', state.drawing);
- store.set('cc.last', { ...result, drawing: painted });
+ store.set('cc.last', { ...result, drawing: sent });
setStatusLink(t('done.body', { n: result.count, repo }), state.user.url, t('done.view'));
} catch (error) {
- showError(error);
+ setStatus(errorText(error), 'error');
} finally {
progress(0, 0);
setBusy(false);
@@ -523,13 +974,12 @@
try {
await state.api.undoPaint(last);
store.set('cc.last', null);
- snapshot();
+ remember();
Object.assign(state.drawing, last.drawing ?? {});
store.set('cc.drawing', state.drawing);
setStatus(t('undoPaint.done'), 'ok');
} catch (error) {
- if (error.message === 'MOVED') setStatus(t('undoPaint.moved'), 'error');
- else showError(error);
+ setStatus(error.message === 'MOVED' ? t('undoPaint.moved') : errorText(error), 'error');
} finally {
setBusy(false);
renderLast();
@@ -544,23 +994,36 @@
});
// ---------- desktop app ----------
- // Inside the Tauri desktop app, open external links in the system browser.
- const desktop = window.__TAURI__;
+ function openExternal(url) {
+ if (desktop) desktop.opener.openUrl(url).catch(() => window.open(url, '_blank'));
+ else window.open(url, '_blank', 'noopener');
+ }
+
if (desktop) {
+ // Open external links in the system browser instead of the app window.
document.addEventListener('click', (event) => {
const link = event.target.closest('a[href^="http"]');
if (!link) return;
event.preventDefault();
- desktop.opener.openUrl(link.href).catch(() => window.open(link.href, '_blank'));
+ openExternal(link.href);
});
+ $('quick-login').hidden = false;
+ $('device-login').hidden = !OAUTH_CLIENT_ID;
+ $('gh-login').hidden = false;
+ $('or-token').hidden = false;
}
// ---------- start ----------
state.weeks = offlineWeeks();
- state.strength ??= 4;
+ state.cursor = { col: state.weeks.length - 1, row: 0 };
+ $('repo').replaceChildren(new Option(t('repo.none'), ''));
+ $('range').replaceChildren(new Option(t('range.past'), ''));
$('strength').value = state.strength;
+ $('avoid').checked = state.avoid;
+ if (store.get('cc.zoom')) $('zoom').click();
selectLevel(4);
applyLanguage();
+ renderMode();
const saved = savedToken();
// The desktop app keeps its own storage, so remember the token by default there.
$('remember').checked = saved.token ? saved.remember : Boolean(desktop);
diff --git a/web/src/github.js b/web/src/github.js
index 24a9b8b..8bd938c 100644
--- a/web/src/github.js
+++ b/web/src/github.js
@@ -12,17 +12,22 @@ window.CC_GitHub = (() => {
function create(token, { onWait } = {}) {
async function request(path, { method = 'GET', body } = {}, attempt = 0) {
- const response = await fetch(API + path, {
- method,
- headers: {
- Accept: 'application/vnd.github+json',
- Authorization: `Bearer ${token}`,
- 'X-GitHub-Api-Version': '2022-11-28',
- ...(body ? { 'Content-Type': 'application/json' } : {}),
- },
- body: body ? JSON.stringify(body) : undefined,
- cache: 'no-store',
- });
+ let response;
+ try {
+ response = await fetch(API + path, {
+ method,
+ headers: {
+ Accept: 'application/vnd.github+json',
+ Authorization: `Bearer ${token}`,
+ 'X-GitHub-Api-Version': '2022-11-28',
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
+ },
+ body: body ? JSON.stringify(body) : undefined,
+ cache: 'no-store',
+ });
+ } catch {
+ throw new GitHubError(0, 'NETWORK');
+ }
if (response.status === 204) return null;
const data = await response.json().catch(() => null);
if (response.ok) return data;
@@ -94,7 +99,7 @@ window.CC_GitHub = (() => {
const data = await graphql(`query($from: DateTime, $to: DateTime) {
viewer { contributionsCollection(from: $from, to: $to) {
contributionYears
- contributionCalendar { weeks { contributionDays { date weekday contributionCount contributionLevel } } }
+ contributionCalendar { totalContributions weeks { contributionDays { date weekday contributionCount contributionLevel } } }
} }
}`, variables);
const collection = data.viewer.contributionsCollection;
@@ -106,7 +111,7 @@ window.CC_GitHub = (() => {
}
return slots;
});
- return { weeks, years: collection.contributionYears };
+ return { weeks, years: collection.contributionYears, total: collection.contributionCalendar.totalContributions };
},
// Creates one empty commit per entry of `dates` (repeated dates allowed)
diff --git a/web/src/i18n.js b/web/src/i18n.js
index 070414b..951ac5d 100644
--- a/web/src/i18n.js
+++ b/web/src/i18n.js
@@ -1,118 +1,252 @@
window.CC_I18N = {
en: {
tagline: 'Draw on your GitHub contribution graph',
- 'connect.title': 'Connect GitHub',
- 'connect.body': 'Paste a personal access token with the repo scope. It is sent only to api.github.com.',
+ 'step.connect': 'Sign in to GitHub',
+ 'step.repo': 'Choose a repository',
+ 'step.draw': 'Draw',
+ 'step.paint': 'Send to GitHub',
+
+ 'connect.device': 'Sign in with GitHub',
+ 'connect.gh': 'Use my GitHub CLI login',
+ 'connect.or': 'or paste a token',
'connect.placeholder': 'ghp_… or github_pat_…',
'connect.button': 'Connect',
'connect.remember': 'Remember on this device',
'connect.create': 'Create a token',
- 'connect.gh': 'Using GitHub CLI? Run gh auth token and paste the output.',
+ 'connect.help': 'How do I get a token?',
+ 'connect.help1': 'Click “Create a token”. GitHub opens with the right permission (repo) already ticked.',
+ 'connect.help2': 'Scroll down and click the green “Generate token” button.',
+ 'connect.help3': 'Copy the code that starts with ghp_ and paste it above.',
+ 'connect.privacy': 'Your token is only sent to GitHub (api.github.com).',
+ 'device.title': 'Enter this code on GitHub',
+ 'device.body': 'A browser window has opened. Paste the code (it’s already copied), then click Authorize.',
+ 'device.open': 'Open the page again',
+ 'device.waiting': 'Waiting for you to authorize…',
+ 'device.expired': 'The code expired. Please try again.',
+ 'device.denied': 'Sign-in was cancelled.',
+ 'gh.missing': 'GitHub CLI isn’t installed or isn’t signed in. Use one of the other options.',
'account.signout': 'Sign out',
- 'repo.label': 'Repository',
+ 'account.total': '{n} contributions in this range',
+
'repo.none': 'Select a repository…',
- 'repo.new': 'New',
- 'repo.namePlaceholder': 'contribution-art',
+ 'repo.new': 'New repository',
+ 'repo.namePlaceholder': 'commit-canvas-art',
'repo.private': 'Private',
'repo.create': 'Create',
'repo.cancel': 'Cancel',
- 'repo.privateHint': 'Private repo: enable “Private contributions” on your profile so they show on the graph.',
- 'year.label': 'Year',
- 'year.past': 'Past year',
- refresh: 'Reload graph',
+ 'repo.suggest': 'No repository for your art yet? Click “New repository” — one click and it’s ready.',
+ 'repo.privateHint': 'This repository is private: turn on “Private contributions” on your GitHub profile, or the drawing won’t be visible to others.',
+ 'repo.realHint': 'Tip: use a separate repository just for drawing, so these commits don’t mix with a real project.',
+ 'repo.visibilityHint': 'Public repositories show up on your graph right away.',
+
+ 'range.label': 'Range',
+ 'range.past': 'Past year',
+ refresh: 'Reload my contributions',
+ 'mode.edit': 'Edit',
+ 'mode.preview': 'Preview result',
'tool.eraser': 'Eraser',
- 'tool.level': 'Shade',
- 'text.placeholder': 'Text',
- 'text.add': 'Add',
- 'shift.left': 'Shift left',
- 'shift.right': 'Shift right',
+ 'tool.commits': '{n}×',
+ 'text.placeholder': 'Type text…',
+ 'text.add': 'Stamp text',
+ 'template.label': 'Templates',
+ 'template.heart': '♥ Heart',
+ 'template.hello': 'HELLO',
+ 'template.wave': '≋ Wave',
+ 'template.stars': '✦ Stars',
+ 'template.replace': 'Replace the current drawing with this template?',
+ 'shift.left': 'Move drawing left',
+ 'shift.right': 'Move drawing right',
undo: 'Undo',
+ redo: 'Redo',
clear: 'Clear',
+ zoom: 'Bigger squares',
+ pan: 'Scroll mode',
+ 'grid.label': 'Drawing grid, one square per day',
+ 'grid.help': 'Click or drag to draw. Click a square again to erase it. Keyboard: arrow keys to move, Space to draw, 0–4 to pick a shade.',
+ 'legend.existing': 'Already on GitHub',
+ 'legend.planned': 'Will be added',
+ 'legend.blocked': 'Skipped (day already has contributions)',
+ 'legend.preview': 'Estimated look after sending (GitHub decides the final shades).',
+ 'avoid.label': 'Don’t add commits on days that already have contributions',
less: 'Less',
more: 'More',
- 'legend.existing': 'Faded = contributions you already have',
- 'summary.pixels': '{n} days',
- 'summary.commits': '{n} commits',
- 'strength.label': 'Commits for darkest shade',
- 'paint.button': 'Paint to GitHub',
- 'paint.needConnect': 'Connect GitHub and choose a repository to paint.',
- 'confirm.title': 'Paint to GitHub?',
- 'confirm.body': 'This creates {commits} empty commits across {days} days in {repo} (branch {branch}) and pushes them. New squares usually show up within minutes, sometimes up to 24 hours.',
- 'confirm.note': 'Decorative commits are not real work — please use this for fun, in a repository you own.',
- 'confirm.ok': 'Paint',
+
+ 'summary.commits': 'Commits',
+ 'summary.days': 'Days',
+ 'summary.range': 'Dates',
+ 'summary.skipped': '{n} days skipped',
+ 'strength.label': 'Commits for the darkest shade',
+ 'strength.help': 'Suggested automatically from your activity so the drawing stands out.',
+ 'paint.button': 'Send to GitHub',
+ 'paint.needConnect': 'Sign in and choose a repository first (steps 1 and 2).',
+ 'paint.empty': 'Draw something first.',
+
+ 'confirm.title': 'Send this drawing to GitHub?',
+ 'confirm.body': 'Commit Canvas will add empty commits to the repository below. New squares usually appear within minutes, occasionally up to 24 hours.',
+ 'confirm.account': 'Account',
+ 'confirm.repo': 'Repository',
+ 'confirm.branch': 'Branch',
+ 'confirm.commits': 'Commits',
+ 'confirm.dates': 'Dates',
+ 'confirm.many': 'I understand this creates a lot of commits.',
+ 'confirm.note': 'Decorative commits aren’t real work — keep it fun and honest.',
+ 'confirm.ok': 'Send',
'confirm.cancel': 'Cancel',
'progress.prepare': 'Preparing repository…',
'progress.commits': 'Creating commits {done}/{total}…',
'progress.push': 'Pushing…',
- 'progress.wait': 'GitHub rate limit — waiting {s}s…',
- 'done.body': 'Painted {n} commits to {repo}.',
- 'done.view': 'View profile',
- 'last.title': 'Last paint: {n} commits to {repo}',
- 'last.undo': 'Undo last paint',
- 'undoPaint.confirm': 'Reset {repo} ({branch}) to the commit before the last paint? The painted commits will be removed from the branch.',
- 'undoPaint.done': 'Last paint removed.',
- 'undoPaint.moved': 'The branch has changed since the last paint, so it can’t be undone automatically.',
- 'error.token': 'This token was rejected by GitHub.',
+ 'progress.wait': 'GitHub asked us to slow down — waiting {s}s…',
+ 'done.body': 'Done! {n} commits added to {repo}.',
+ 'done.view': 'Open my profile',
+ 'last.title': 'Last send: {n} commits to {repo}',
+ 'last.undo': 'Undo last send',
+ 'undoPaint.confirm': 'Move {repo} ({branch}) back to how it was before the last send? Those commits will be removed.',
+ 'undoPaint.done': 'Last send undone.',
+ 'undoPaint.moved': 'The repository changed after the last send, so it can’t be undone automatically.',
+
+ 'more.title': 'More tools',
+ 'design.save': 'Save design',
+ 'design.open': 'Open design',
+ 'design.saved': 'Design saved.',
+ 'design.loaded': 'Design opened.',
+ 'design.invalid': 'This file isn’t a Commit Canvas design.',
+ 'script.title': 'Offline script',
+ 'script.help': 'Prefer doing it yourself? Export a script that creates the same commits in a local Git repository; then run git push.',
+ 'script.email': 'Commit email (must belong to your GitHub account)',
+ 'script.bash': 'Export Bash script',
+ 'script.powershell': 'Export PowerShell script',
+ 'script.saved': 'Script saved. Read it before running it inside your repository folder.',
+ 'script.badEmail': 'Enter a valid email address.',
+
+ 'error.token': 'GitHub didn’t accept this token. Check that you copied all of it.',
'error.generic': 'Something went wrong: {msg}',
- 'error.tooMany': 'Too many commits ({n}). Keep a single paint under {max}.',
- 'error.noPush': 'You don’t have push access to this repository.',
+ 'error.tooMany': 'That’s {n} commits — please keep one send under {max}.',
+ 'error.noPush': 'You can’t push to this repository.',
+ 'error.network': 'Couldn’t reach GitHub. Check your internet connection.',
},
zh: {
- tagline: '在 GitHub 贡献图上画画',
- 'connect.title': '连接 GitHub',
- 'connect.body': '粘贴一个带 repo 权限的 Personal Access Token,它只会发送给 api.github.com。',
+ tagline: '在 GitHub 贡献图(绿墙)上画画',
+ 'step.connect': '登录 GitHub',
+ 'step.repo': '选择仓库',
+ 'step.draw': '画画',
+ 'step.paint': '提交到 GitHub',
+
+ 'connect.device': '用 GitHub 账号登录',
+ 'connect.gh': '使用本机 GitHub CLI 的登录',
+ 'connect.or': '或者粘贴 Token',
'connect.placeholder': 'ghp_… 或 github_pat_…',
'connect.button': '连接',
'connect.remember': '在此设备上记住',
'connect.create': '创建 Token',
- 'connect.gh': '装了 GitHub CLI?运行 gh auth token 然后粘贴输出即可。',
+ 'connect.help': '怎么获取 Token?',
+ 'connect.help1': '点击「创建 Token」,GitHub 会打开并已经帮你勾好所需权限(repo)。',
+ 'connect.help2': '拉到页面最下面,点绿色的「Generate token」按钮。',
+ 'connect.help3': '复制以 ghp_ 开头的那串字符,粘贴到上面的输入框。',
+ 'connect.privacy': 'Token 只会发送给 GitHub(api.github.com)。',
+ 'device.title': '在 GitHub 页面输入这个验证码',
+ 'device.body': '浏览器已经打开,验证码也已复制好。粘贴后点击 Authorize 即可。',
+ 'device.open': '重新打开页面',
+ 'device.waiting': '等待你在浏览器里授权…',
+ 'device.expired': '验证码已过期,请重试。',
+ 'device.denied': '登录已取消。',
+ 'gh.missing': '没有检测到已登录的 GitHub CLI,请用其他方式登录。',
'account.signout': '退出',
- 'repo.label': '仓库',
+ 'account.total': '这段时间共有 {n} 次贡献',
+
'repo.none': '选择一个仓库…',
- 'repo.new': '新建',
- 'repo.namePlaceholder': 'contribution-art',
+ 'repo.new': '新建仓库',
+ 'repo.namePlaceholder': 'commit-canvas-art',
'repo.private': '私有',
'repo.create': '创建',
'repo.cancel': '取消',
- 'repo.privateHint': '私有仓库:需要在个人主页开启“Private contributions”才会显示在贡献图上。',
- 'year.label': '年份',
- 'year.past': '最近一年',
- refresh: '刷新贡献图',
+ 'repo.suggest': '还没有用来画画的仓库?点「新建仓库」,一键就好。',
+ 'repo.privateHint': '这是私有仓库:需要在 GitHub 个人主页开启「Private contributions」,别人才能看到你的画。',
+ 'repo.realHint': '建议:用一个专门画画的仓库,避免这些 commit 混进正式项目。',
+ 'repo.visibilityHint': '公开仓库的贡献会直接显示在绿墙上。',
+
+ 'range.label': '范围',
+ 'range.past': '最近一年',
+ refresh: '重新读取我的贡献',
+ 'mode.edit': '编辑',
+ 'mode.preview': '预览效果',
'tool.eraser': '橡皮擦',
- 'tool.level': '深浅',
- 'text.placeholder': '文字',
- 'text.add': '添加',
+ 'tool.commits': '{n}×',
+ 'text.placeholder': '输入文字…',
+ 'text.add': '印上文字',
+ 'template.label': '模板',
+ 'template.heart': '♥ 爱心',
+ 'template.hello': 'HELLO',
+ 'template.wave': '≋ 波浪',
+ 'template.stars': '✦ 星星',
+ 'template.replace': '用这个模板替换当前的画吗?',
'shift.left': '整体左移',
'shift.right': '整体右移',
undo: '撤销',
+ redo: '重做',
clear: '清空',
+ zoom: '放大格子',
+ pan: '滑动模式',
+ 'grid.label': '画布,每格代表一天',
+ 'grid.help': '点击或拖动来画,再点一次同一格可以擦掉。键盘:方向键移动,空格画,0–4 选深浅。',
+ 'legend.existing': '已有的贡献',
+ 'legend.planned': '将要添加',
+ 'legend.blocked': '已跳过(这天已有贡献)',
+ 'legend.preview': '提交后的大致效果(最终深浅由 GitHub 计算)。',
+ 'avoid.label': '已经有贡献的日子不再添加 commit',
less: '少',
more: '多',
- 'legend.existing': '半透明 = 你已有的贡献',
- 'summary.pixels': '{n} 天',
- 'summary.commits': '{n} 个 commit',
+
+ 'summary.commits': 'commit 数',
+ 'summary.days': '天数',
+ 'summary.range': '日期',
+ 'summary.skipped': '跳过 {n} 天',
'strength.label': '最深一格的 commit 数',
- 'paint.button': '画到 GitHub',
- 'paint.needConnect': '先连接 GitHub 并选择仓库才能绘制。',
- 'confirm.title': '确认绘制到 GitHub?',
- 'confirm.body': '将在 {repo}(分支 {branch})中创建 {commits} 个空 commit,覆盖 {days} 天,并推送上去。新的格子通常几分钟内出现,最长可能需要 24 小时。',
- 'confirm.note': '装饰性的 commit 不代表真实工作量——请只在自己的仓库里娱乐使用。',
- 'confirm.ok': '开始绘制',
+ 'strength.help': '已根据你平时的贡献自动推荐,让画更显眼。',
+ 'paint.button': '提交到 GitHub',
+ 'paint.needConnect': '请先完成第 1、2 步:登录并选择仓库。',
+ 'paint.empty': '先画点东西吧。',
+
+ 'confirm.title': '把这幅画提交到 GitHub?',
+ 'confirm.body': '将在下面的仓库里添加空 commit。新的格子通常几分钟内出现,偶尔需要最多 24 小时。',
+ 'confirm.account': '账号',
+ 'confirm.repo': '仓库',
+ 'confirm.branch': '分支',
+ 'confirm.commits': 'commit 数',
+ 'confirm.dates': '日期',
+ 'confirm.many': '我知道这次会创建很多 commit。',
+ 'confirm.note': '装饰性的 commit 不代表真实工作量,娱乐就好。',
+ 'confirm.ok': '提交',
'confirm.cancel': '取消',
'progress.prepare': '正在准备仓库…',
'progress.commits': '正在创建 commit {done}/{total}…',
'progress.push': '正在推送…',
- 'progress.wait': '触发 GitHub 频率限制,等待 {s} 秒…',
- 'done.body': '已向 {repo} 绘制 {n} 个 commit。',
- 'done.view': '查看主页',
- 'last.title': '上次绘制:{n} 个 commit → {repo}',
- 'last.undo': '撤销上次绘制',
- 'undoPaint.confirm': '把 {repo}({branch})重置到上次绘制之前?绘制的 commit 会从分支中移除。',
- 'undoPaint.done': '已撤销上次绘制。',
- 'undoPaint.moved': '上次绘制之后分支已有新的提交,无法自动撤销。',
- 'error.token': 'GitHub 拒绝了这个 Token。',
+ 'progress.wait': 'GitHub 要求放慢速度,等待 {s} 秒…',
+ 'done.body': '完成!已向 {repo} 添加 {n} 个 commit。',
+ 'done.view': '打开我的主页',
+ 'last.title': '上次提交:{n} 个 commit → {repo}',
+ 'last.undo': '撤销上次提交',
+ 'undoPaint.confirm': '把 {repo}({branch})恢复到上次提交之前?那些 commit 会被移除。',
+ 'undoPaint.done': '已撤销上次提交。',
+ 'undoPaint.moved': '上次提交之后仓库有了新的变化,无法自动撤销。',
+
+ 'more.title': '更多工具',
+ 'design.save': '保存设计',
+ 'design.open': '打开设计',
+ 'design.saved': '设计已保存。',
+ 'design.loaded': '设计已打开。',
+ 'design.invalid': '这不是 Commit Canvas 的设计文件。',
+ 'script.title': '离线脚本',
+ 'script.help': '想自己动手?导出一个脚本,在本地 Git 仓库里创建同样的 commit,然后 git push。',
+ 'script.email': 'commit 邮箱(必须属于你的 GitHub 账号)',
+ 'script.bash': '导出 Bash 脚本',
+ 'script.powershell': '导出 PowerShell 脚本',
+ 'script.saved': '脚本已保存。运行前请先看一遍,并在仓库文件夹里运行。',
+ 'script.badEmail': '请输入有效的邮箱地址。',
+
+ 'error.token': 'GitHub 不接受这个 Token,请检查是否完整复制。',
'error.generic': '出错了:{msg}',
- 'error.tooMany': 'commit 太多({n} 个),单次请控制在 {max} 个以内。',
+ 'error.tooMany': '一共 {n} 个 commit,单次请控制在 {max} 个以内。',
'error.noPush': '你没有这个仓库的推送权限。',
+ 'error.network': '连不上 GitHub,请检查网络。',
},
};
diff --git a/web/styles.css b/web/styles.css
index 664ba5d..1369d9d 100644
--- a/web/styles.css
+++ b/web/styles.css
@@ -6,7 +6,9 @@
--border: #d0d7de;
--accent: #1f883d;
--accent-hover: #1a7f37;
+ --accent-soft: #dafbe1;
--danger: #cf222e;
+ --mark: #ffffff;
--l0: #ebedf0;
--l1: #9be9a8;
--l2: #40c463;
@@ -26,7 +28,9 @@
--border: #30363d;
--accent: #238636;
--accent-hover: #2ea043;
+ --accent-soft: #12261e;
--danger: #f85149;
+ --mark: #0d1117;
--l0: #21262d;
--l1: #0e4429;
--l2: #006d32;
@@ -45,15 +49,16 @@ body {
}
a { color: var(--accent); }
-h1, h2 { margin: 0; }
+h1, h2, h3 { margin: 0; }
[hidden] { display: none !important; }
.top, main, .foot {
- max-width: 920px;
+ max-width: 940px;
margin: 0 auto;
padding: 0 16px;
}
+/* ---------- header ---------- */
.top {
display: flex;
align-items: center;
@@ -62,53 +67,71 @@ h1, h2 { margin: 0; }
padding-top: 24px;
padding-bottom: 16px;
}
-
.brand { display: flex; align-items: center; gap: 12px; }
.brand h1 { font-size: 18px; }
.brand p { margin: 0; color: var(--muted); font-size: 13px; }
-
-.logo {
- display: grid;
- grid-template-columns: repeat(2, 12px);
- gap: 3px;
-}
+.logo { display: grid; grid-template-columns: repeat(2, 12px); gap: 3px; }
.logo i { width: 12px; height: 12px; border-radius: 3px; background: var(--l1); }
.logo i:nth-child(2) { background: var(--l2); }
.logo i:nth-child(3) { background: var(--l3); }
.logo i:nth-child(4) { background: var(--l4); }
-
.top-actions, .account { display: flex; align-items: center; gap: 8px; }
.account img { border-radius: 50%; }
.account a { color: var(--text); font-weight: 600; text-decoration: none; }
+/* ---------- layout ---------- */
main { display: grid; gap: 16px; }
main > * { min-width: 0; }
.card {
background: var(--card);
border: 1px solid var(--border);
- border-radius: 8px;
- padding: 16px;
+ border-radius: 10px;
+ padding: 18px;
}
-.card h2 { font-size: 16px; margin-bottom: 4px; }
+
+.step h2 {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 16px;
+ margin-bottom: 12px;
+}
+.num {
+ display: inline-grid;
+ place-items: center;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ background: var(--l0);
+ color: var(--muted);
+ font-size: 13px;
+ font-weight: 700;
+}
+.step.done .num { background: var(--accent); color: #fff; font-size: 0; }
+.step.done .num::after { content: "✓"; font-size: 13px; }
+.step-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }
+.step-head h2 { margin-bottom: 0; }
.muted { color: var(--muted); }
.small-text { font-size: 12px; }
-.hint { margin: 12px 0 0; color: var(--muted); font-size: 12px; }
+.hint { margin: 10px 0 0; color: var(--muted); font-size: 12px; }
+.done-line { margin: 0; font-weight: 600; }
+.total { margin: 6px 0 0; }
.row { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
+.row:first-child { margin-top: 0; }
.row.wrap { flex-wrap: wrap; }
.row.end { justify-content: flex-end; }
-#setup .row:first-child { margin-top: 0; align-items: flex-end; }
-.row.small-text { justify-content: space-between; margin-top: 8px; }
+.row.spread { justify-content: space-between; margin-top: 8px; }
+.row.tight { margin: 0; gap: 6px; }
.grow { flex: 1 1 220px; }
-.field { display: grid; gap: 4px; font-size: 12px; color: var(--muted); }
-
+/* ---------- controls ---------- */
input, select, button { font: inherit; color: inherit; }
input, select {
- height: 32px;
+ height: 34px;
padding: 0 10px;
background: var(--bg);
border: 1px solid var(--border);
@@ -116,15 +139,15 @@ input, select {
min-width: 0;
}
#connect-form input { flex: 1; }
-input:focus-visible, select:focus-visible, button:focus-visible {
+input:focus-visible, select:focus-visible, button:focus-visible, .graph:focus-visible, summary:focus-visible {
outline: 2px solid var(--accent);
- outline-offset: 1px;
+ outline-offset: 2px;
}
-.check { display: inline-flex; align-items: center; gap: 6px; }
+.check { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
.check input { height: auto; }
button {
- height: 32px;
+ height: 34px;
padding: 0 12px;
border-radius: 6px;
border: 1px solid var(--border);
@@ -135,7 +158,7 @@ button {
button:hover:not(:disabled) { background: var(--bg); }
button:disabled { opacity: .5; cursor: not-allowed; }
button.small { height: 28px; padding: 0 10px; font-size: 12px; }
-button.icon { width: 32px; padding: 0; }
+button.icon { width: 34px; padding: 0; }
button.primary {
background: var(--accent);
border-color: transparent;
@@ -143,59 +166,105 @@ button.primary {
font-weight: 600;
}
button.primary:hover:not(:disabled) { background: var(--accent-hover); }
-button.big { height: 40px; padding: 0 20px; }
+button.big { height: 42px; padding: 0 24px; font-size: 15px; }
+button[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); }
+/* ---------- sign in ---------- */
+.divider {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin: 14px 0 0;
+}
+.divider::before, .divider::after { content: ""; flex: 1; border-top: 1px solid var(--border); }
+.device {
+ margin-top: 4px;
+ padding: 14px;
+ border: 1px dashed var(--accent);
+ border-radius: 8px;
+ text-align: center;
+}
+.device p { margin: 0; }
+.device .row { justify-content: center; }
+.code {
+ margin: 6px 0 !important;
+ font: 700 28px/1.2 ui-monospace, SFMono-Regular, Consolas, monospace;
+ letter-spacing: 4px;
+ user-select: all;
+}
+.help { margin-top: 10px; font-size: 13px; }
+.help summary { cursor: pointer; color: var(--accent); width: max-content; }
+.help ol { margin: 8px 0; padding-left: 20px; }
+.help li { margin: 2px 0; }
+
+/* ---------- drawing ---------- */
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
- gap: 12px;
- margin-bottom: 16px;
+ gap: 10px;
+ margin-top: 14px;
}
-.tools { display: flex; gap: 6px; }
-
+.tools { display: flex; flex-wrap: wrap; gap: 6px; }
+.brushes { display: flex; gap: 6px; }
+.brush {
+ display: grid;
+ justify-items: center;
+ gap: 2px;
+ height: auto;
+ padding: 4px 6px;
+ min-width: 46px;
+ border-color: transparent;
+ background: transparent;
+}
+.brush small { font-size: 11px; color: var(--muted); }
+.brush[aria-checked="true"] { border-color: var(--text); background: var(--bg); }
.swatch {
- width: 28px;
- height: 28px;
- padding: 0;
- border-radius: 6px;
- border: 2px solid transparent;
- background-clip: padding-box;
+ display: grid;
+ place-items: center;
+ width: 24px;
+ height: 24px;
+ border-radius: 5px;
+ font-style: normal;
+ font-size: 12px;
}
-.swatch.l1 { background-color: var(--l1); }
-.swatch.l2 { background-color: var(--l2); }
-.swatch.l3 { background-color: var(--l3); }
-.swatch.l4 { background-color: var(--l4); }
-.swatch.eraser { background: var(--l0); color: var(--muted); font-size: 13px; }
-.swatch[aria-checked="true"] { border-color: var(--text); }
-.swatch:hover:not(:disabled) { filter: brightness(1.08); }
-.swatch.l1:hover:not(:disabled) { background-color: var(--l1); }
-.swatch.l2:hover:not(:disabled) { background-color: var(--l2); }
-.swatch.l3:hover:not(:disabled) { background-color: var(--l3); }
-.swatch.l4:hover:not(:disabled) { background-color: var(--l4); }
-.swatch.eraser:hover:not(:disabled) { background: var(--l0); }
+.swatch.l1 { background: var(--l1); }
+.swatch.l2 { background: var(--l2); }
+.swatch.l3 { background: var(--l3); }
+.swatch.l4 { background: var(--l4); }
+.swatch.eraser { background: var(--l0); color: var(--muted); }
.text-tool { display: flex; gap: 6px; }
-.text-tool input { width: 120px; }
+.text-tool input { width: 150px; }
+.templates { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
-.graph-scroll { overflow-x: auto; padding-bottom: 4px; }
+.segmented { display: inline-flex; }
+.segmented button { border-radius: 0; height: 30px; font-size: 12px; }
+.segmented button:first-child { border-radius: 6px 0 0 6px; }
+.segmented button:last-child { border-radius: 0 6px 6px 0; margin-left: -1px; }
+.graph-scroll { overflow-x: auto; margin-top: 16px; padding: 4px 2px; }
.graph {
display: grid;
- grid-template-columns: 28px repeat(var(--cols, 53), var(--cell));
+ grid-template-columns: 30px repeat(var(--cols, 53), var(--cell));
grid-template-rows: 16px repeat(7, var(--cell));
gap: var(--gap);
width: max-content;
margin: 0 auto;
user-select: none;
touch-action: none;
+ border-radius: 4px;
}
+.graph.zoomed { --cell: 17px; --gap: 4px; }
+.graph.panning { touch-action: pan-x; }
+.graph.panning .cell { cursor: grab; }
.month, .weekday { font-size: 10px; color: var(--muted); line-height: var(--cell); white-space: nowrap; }
-.month { grid-row: 1; line-height: 16px; overflow: visible; }
+.month { grid-row: 1; line-height: 16px; }
.weekday { grid-column: 1; }
.cell {
+ position: relative;
display: block;
width: var(--cell);
height: var(--cell);
@@ -210,73 +279,117 @@ button.big { height: 40px; padding: 0 20px; }
.cell.l2 { background: var(--l2); }
.cell.l3 { background: var(--l3); }
.cell.l4 { background: var(--l4); }
-.cell.existing { opacity: .35; }
-.graph .cell:hover { outline: 1px solid var(--text); }
+/* Planned squares get a dot so they stand apart from existing contributions. */
+.cell.drawn::after {
+ content: "";
+ position: absolute;
+ inset: 30%;
+ border-radius: 50%;
+ background: var(--mark);
+ opacity: .9;
+}
+.cell.blocked { opacity: .45; outline: 1px dashed var(--danger); outline-offset: 0; }
+.graph .cell:hover { outline: 1px solid var(--text); outline-offset: 0; }
+.graph:focus-visible .cell.cursor { outline: 2px solid var(--accent); outline-offset: 0; }
.legend {
display: flex;
flex-wrap: wrap;
+ align-items: center;
justify-content: space-between;
- gap: 8px;
- margin-top: 12px;
+ gap: 8px 16px;
+ margin-top: 8px;
}
-.scale { display: inline-flex; align-items: center; gap: 3px; color: var(--muted); }
-.scale .cell { width: 10px; height: 10px; }
-.scale span:first-child { margin-right: 2px; }
-.scale span:last-child { margin-left: 2px; }
+.keys { display: flex; flex-wrap: wrap; gap: 6px 14px; }
+.keys > span { display: inline-flex; align-items: center; gap: 6px; }
+.legend .cell { width: 11px; height: 11px; }
+.scale { display: inline-flex; align-items: center; gap: 3px; }
+.scale span:first-child { margin-right: 3px; }
+.scale span:last-child { margin-left: 3px; }
+.avoid { margin-top: 12px; }
+#grid-help { margin: 8px 0 0; }
-.paint {
+/* ---------- send ---------- */
+.stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
+.stats div { display: grid; gap: 2px; padding: 10px 12px; background: var(--bg); border-radius: 8px; }
+.stats strong { font-size: 17px; overflow-wrap: anywhere; }
+#sum-skipped { margin: 8px 0 0; }
+.paint-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
+ margin-top: 14px;
}
-.summary { display: flex; flex-wrap: wrap; align-items: baseline; gap: 12px; }
-.summary strong { font-size: 16px; }
-.strength { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); }
-.strength input { width: 64px; height: 28px; }
-
-.status { margin: 0; min-height: 1.5em; color: var(--muted); }
+.strength { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; max-width: 520px; }
+.strength input { width: 64px; height: 30px; }
+.strength .muted { flex-basis: 100%; }
+.status { margin: 12px 0 0; min-height: 1.5em; color: var(--muted); }
+.status:empty { margin: 0; min-height: 0; }
.status.ok { color: var(--accent); }
.status.error { color: var(--danger); }
-
-.progress {
- height: 6px;
- border-radius: 3px;
- background: var(--l0);
- overflow: hidden;
-}
+.progress { height: 6px; margin-top: 10px; border-radius: 3px; background: var(--l0); overflow: hidden; }
.progress div { height: 100%; width: 0; background: var(--l3); transition: width .2s; }
-
.last {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
+ margin-top: 14px;
+ padding-top: 12px;
+ border-top: 1px solid var(--border);
color: var(--muted);
font-size: 13px;
}
-.foot { padding-top: 32px; padding-bottom: 32px; text-align: center; }
+/* ---------- more ---------- */
+.more summary { cursor: pointer; font-weight: 600; }
+.more[open] summary { margin-bottom: 14px; }
+.more h3 { margin-top: 18px; font-size: 14px; }
+.more p { margin: 4px 0 0; }
+
+.foot { padding-top: 28px; padding-bottom: 32px; text-align: center; }
.foot a { color: inherit; }
+/* ---------- dialog ---------- */
dialog {
- max-width: min(440px, calc(100vw - 32px));
- padding: 20px;
+ width: min(460px, calc(100vw - 32px));
+ padding: 22px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--card);
color: var(--text);
}
-dialog::backdrop { background: rgb(0 0 0 / 40%); }
-dialog h2 { font-size: 16px; }
-dialog p { margin: 12px 0 0; }
+dialog::backdrop { background: rgb(0 0 0 / 45%); }
+dialog h2 { font-size: 17px; }
+dialog p { margin: 10px 0 0; }
+dialog dl {
+ display: grid;
+ grid-template-columns: max-content 1fr;
+ gap: 6px 14px;
+ margin: 14px 0 0;
+ padding: 12px;
+ background: var(--bg);
+ border-radius: 8px;
+ font-size: 13px;
+}
+dialog dt { color: var(--muted); }
+dialog dd { margin: 0; font-weight: 600; overflow-wrap: anywhere; }
+dialog .check { margin-top: 12px; }
+dialog .row.end { margin-top: 18px; }
+
+.touch-only { display: none; }
+@media (pointer: coarse) {
+ .touch-only { display: inline-block; }
+}
@media (max-width: 600px) {
.top { flex-wrap: wrap; }
- .text-tool { flex: 1; }
+ .text-tool { flex: 1 1 100%; }
.text-tool input { flex: 1; width: auto; }
- .paint button.big { width: 100%; }
+ .paint-row button.big { width: 100%; }
+ .stats { grid-template-columns: 1fr 1fr; }
+ .stats div:last-child { grid-column: 1 / -1; }
}
From 5b31ff217888b33233fdca1f54b7685e830037df Mon Sep 17 00:00:00 2001
From: Cedric <131578574+ced2711@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:14:52 -0500
Subject: [PATCH 3/7] Add the tauri npm script expected by tauri-action
---
package.json | 1 +
1 file changed, 1 insertion(+)
diff --git a/package.json b/package.json
index d430d4c..3521c87 100644
--- a/package.json
+++ b/package.json
@@ -5,6 +5,7 @@
"description": "Draw on your GitHub contribution graph by adding commits to a repository you choose.",
"license": "AGPL-3.0-only",
"scripts": {
+ "tauri": "tauri",
"icons": "tauri icon app-icon.png",
"dev": "tauri dev",
"build": "tauri build"
From 675f2a51bec49dbba25432909485ed0cf1606443 Mon Sep 17 00:00:00 2001
From: Cedric <131578574+ced2711@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:18:27 -0500
Subject: [PATCH 4/7] Recognize drawing repositories and offer one-click
creation
After sign-in, repositories created by Commit Canvas, the original
version's managed commit-canvas-* repositories and art repositories are
listed under "Ready for drawing" and picked automatically. Without one,
a single button creates a public commit-canvas-art repository.
CI also uploads the portable Windows executable for pull request builds.
---
.github/workflows/build.yml | 8 ++++
web/index.html | 4 ++
web/src/app.js | 92 +++++++++++++++++++++++++++----------
web/src/github.js | 20 ++++++--
web/src/i18n.js | 14 +++++-
web/styles.css | 15 ++++++
6 files changed, 124 insertions(+), 29 deletions(-)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 607b0b0..d994b50 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -68,3 +68,11 @@ jobs:
name: commit-canvas-${{ matrix.platform }}
path: src-tauri/target/**/release/bundle/
if-no-files-found: error
+
+ - name: Upload portable Windows exe
+ if: ${{ !startsWith(github.ref, 'refs/tags/') && matrix.platform == 'windows-latest' }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: commit-canvas-windows-portable
+ path: src-tauri/target/release/commit-canvas.exe
+ if-no-files-found: error
diff --git a/web/index.html b/web/index.html
index 79e9de5..e1e77f9 100644
--- a/web/index.html
+++ b/web/index.html
@@ -77,6 +77,10 @@
1Sign in to GitHub
2Choose a repository
+
+
No repository for drawing found yet.
+
+
diff --git a/web/src/app.js b/web/src/app.js
index 8b578ca..93034f9 100644
--- a/web/src/app.js
+++ b/web/src/app.js
@@ -53,8 +53,7 @@
for (const el of document.querySelectorAll('[data-i18n-aria-label]')) el.setAttribute('aria-label', t(el.dataset.i18nAriaLabel));
const past = $('range').querySelector('option[value=""]');
if (past) past.textContent = t('range.past');
- const none = $('repo').querySelector('option[value=""]');
- if (none) none.textContent = t('repo.none');
+ renderRepoOptions();
buildGraph();
renderAccount();
renderRepoHint();
@@ -725,6 +724,7 @@
$('repo').replaceChildren(new Option(t('repo.none'), ''));
$('range').replaceChildren(new Option(t('range.past'), ''));
$('new-repo').hidden = true;
+ $('repo-quick').hidden = true;
state.weeks = offlineWeeks();
renderAccount();
renderRepoHint();
@@ -809,14 +809,50 @@
}
// A just-created repository may not be listed yet.
if (created && !state.repos.some((r) => r.fullName === created.fullName)) state.repos.unshift(created);
- const current = created?.fullName ?? store.get('cc.repo');
- const el = $('repo');
- el.replaceChildren(new Option(t('repo.none'), ''));
+ await markDrawingRepos();
+ renderRepoOptions();
+ const stored = store.get('cc.repo');
+ const pick = created?.fullName
+ ?? (state.repos.some((r) => r.fullName === stored) ? stored : state.repos.find((r) => r.drawing)?.fullName)
+ ?? '';
+ state.autoPicked = Boolean(pick && !created && pick !== stored);
+ $('repo').value = pick;
+ onRepoChange();
+ }
+
+ // Recognizes repositories meant for drawing: ones this app created, the
+ // original version's managed "commit-canvas-*" repositories, and art repos.
+ async function markDrawingRepos() {
for (const repo of state.repos) {
- el.append(new Option(repo.private ? `${repo.fullName} 🔒` : repo.fullName, repo.fullName));
+ const name = repo.fullName.split('/')[1];
+ repo.drawing = /Commit Canvas/i.test(repo.description)
+ || /^commit-canvas-./i.test(name)
+ || /contribution[-_]?art|commit[-_]?art|gitfiti|graffiti/i.test(name);
}
- el.value = state.repos.some((r) => r.fullName === current) ? current : '';
- onRepoChange();
+ // A repository named exactly "commit-canvas" may be the app itself; the
+ // original version marked its practice repositories with a file.
+ await Promise.all(state.repos
+ .filter((r) => !r.drawing && /\/commit-canvas$/i.test(r.fullName))
+ .map(async (r) => { r.drawing = await state.api.hasFile(r.fullName, '.commit-canvas-managed'); }));
+ }
+
+ function renderRepoOptions() {
+ const el = $('repo');
+ const label = (repo) => (repo.private ? `${repo.fullName} 🔒` : repo.fullName);
+ const group = (key, repos) => {
+ const g = Object.assign(document.createElement('optgroup'), { label: t(key) });
+ g.append(...repos.map((repo) => new Option(label(repo), repo.fullName)));
+ return g;
+ };
+ const drawing = state.repos.filter((r) => r.drawing);
+ const other = state.repos.filter((r) => !r.drawing);
+ const value = el.value;
+ el.replaceChildren(new Option(t('repo.none'), ''));
+ if (drawing.length) el.append(group('repo.groupDrawing', drawing));
+ if (other.length) el.append(group('repo.groupOther', other));
+ el.value = value;
+ $('repo-quick').hidden = !state.user || drawing.length > 0;
+ $('repo-quick-create').textContent = t('repo.quickCreate', { name: suggestName() });
}
function onRepoChange() {
@@ -828,13 +864,13 @@
function renderRepoHint() {
const repo = state.repos.find((r) => r.fullName === $('repo').value);
- let hint = '';
- if (state.user && !repo) hint = t('repo.suggest');
- else if (repo?.private) hint = t('repo.privateHint');
- else if (repo && !/canvas|art|draw|paint|graffiti/i.test(repo.fullName)) hint = t('repo.realHint');
- $('repo-hint').textContent = hint;
- $('repo-hint').hidden = !hint;
- $('repo-new').classList.toggle('primary', Boolean(state.user && !repo));
+ const hints = [];
+ if (state.user && !repo && state.repos.some((r) => r.drawing)) hints.push(t('repo.pick'));
+ if (repo && state.autoPicked) hints.push(t('repo.autoPicked'));
+ if (repo?.private) hints.push(t('repo.privateHint'));
+ else if (repo && !repo.drawing) hints.push(t('repo.realHint'));
+ $('repo-hint').textContent = hints.join(' ');
+ $('repo-hint').hidden = !hints.length;
}
function suggestName() {
@@ -844,22 +880,30 @@
return name;
}
- $('repo').addEventListener('change', onRepoChange);
+ $('repo').addEventListener('change', () => {
+ state.autoPicked = false;
+ onRepoChange();
+ });
+
+ async function createRepo(name, isPrivate) {
+ try {
+ const repo = await state.api.createRepo(name, isPrivate);
+ $('new-repo').hidden = true;
+ await loadRepos({ ...repo, drawing: true });
+ } catch (error) {
+ setStatus(errorText(error), 'error');
+ }
+ }
+ $('repo-quick-create').addEventListener('click', () => createRepo(suggestName(), false));
$('repo-new').addEventListener('click', () => {
$('new-repo').hidden = false;
$('new-repo-name').value = suggestName();
$('new-repo-name').select();
});
$('new-repo-cancel').addEventListener('click', () => { $('new-repo').hidden = true; });
- $('new-repo').addEventListener('submit', async (event) => {
+ $('new-repo').addEventListener('submit', (event) => {
event.preventDefault();
- try {
- const repo = await state.api.createRepo($('new-repo-name').value.trim(), $('new-repo-private').checked);
- $('new-repo').hidden = true;
- await loadRepos(repo);
- } catch (error) {
- setStatus(errorText(error), 'error');
- }
+ createRepo($('new-repo-name').value.trim(), $('new-repo-private').checked);
});
// ---------- contributions ----------
diff --git a/web/src/github.js b/web/src/github.js
index 8bd938c..4927970 100644
--- a/web/src/github.js
+++ b/web/src/github.js
@@ -81,8 +81,22 @@ window.CC_GitHub = (() => {
if (batch.length < 100) break;
}
return list
- .filter((repo) => !repo.fork && !repo.archived)
- .map((repo) => ({ fullName: repo.full_name, private: repo.private, branch: repo.default_branch }));
+ .filter((repo) => !repo.fork && !repo.archived && repo.permissions?.push !== false)
+ .map((repo) => ({
+ fullName: repo.full_name,
+ private: repo.private,
+ branch: repo.default_branch,
+ description: repo.description ?? '',
+ }));
+ },
+
+ async hasFile(fullName, path) {
+ try {
+ await request(`${repoPath(fullName)}/contents/${path}`);
+ return true;
+ } catch {
+ return false;
+ }
},
async createRepo(name, isPrivate) {
@@ -90,7 +104,7 @@ window.CC_GitHub = (() => {
method: 'POST',
body: { name, private: isPrivate, description: 'Contribution art made with Commit Canvas', auto_init: false },
});
- return { fullName: repo.full_name, private: repo.private, branch: repo.default_branch };
+ return { fullName: repo.full_name, private: repo.private, branch: repo.default_branch, description: repo.description ?? '' };
},
// Returns weeks as arrays of 7 slots ({date, count, level} or null).
diff --git a/web/src/i18n.js b/web/src/i18n.js
index 951ac5d..0ac6c18 100644
--- a/web/src/i18n.js
+++ b/web/src/i18n.js
@@ -34,7 +34,12 @@ window.CC_I18N = {
'repo.private': 'Private',
'repo.create': 'Create',
'repo.cancel': 'Cancel',
- 'repo.suggest': 'No repository for your art yet? Click “New repository” — one click and it’s ready.',
+ 'repo.noneFound': 'No repository for drawing found in your account yet.',
+ 'repo.quickCreate': 'Create “{name}” with one click',
+ 'repo.groupDrawing': 'Ready for drawing',
+ 'repo.groupOther': 'Other repositories',
+ 'repo.pick': 'Pick one of the repositories under “Ready for drawing”.',
+ 'repo.autoPicked': 'Picked automatically — you can change it.',
'repo.privateHint': 'This repository is private: turn on “Private contributions” on your GitHub profile, or the drawing won’t be visible to others.',
'repo.realHint': 'Tip: use a separate repository just for drawing, so these commits don’t mix with a real project.',
'repo.visibilityHint': 'Public repositories show up on your graph right away.',
@@ -159,7 +164,12 @@ window.CC_I18N = {
'repo.private': '私有',
'repo.create': '创建',
'repo.cancel': '取消',
- 'repo.suggest': '还没有用来画画的仓库?点「新建仓库」,一键就好。',
+ 'repo.noneFound': '你的账号里还没有用来画画的仓库。',
+ 'repo.quickCreate': '一键创建「{name}」',
+ 'repo.groupDrawing': '适合画画的仓库',
+ 'repo.groupOther': '其他仓库',
+ 'repo.pick': '从「适合画画的仓库」里选一个即可。',
+ 'repo.autoPicked': '已自动选好,可以随时更换。',
'repo.privateHint': '这是私有仓库:需要在 GitHub 个人主页开启「Private contributions」,别人才能看到你的画。',
'repo.realHint': '建议:用一个专门画画的仓库,避免这些 commit 混进正式项目。',
'repo.visibilityHint': '公开仓库的贡献会直接显示在绿墙上。',
diff --git a/web/styles.css b/web/styles.css
index 1369d9d..6774cdd 100644
--- a/web/styles.css
+++ b/web/styles.css
@@ -197,6 +197,21 @@ button[aria-pressed="true"] { background: var(--accent-soft); border-color: var(
.help ol { margin: 8px 0; padding-left: 20px; }
.help li { margin: 2px 0; }
+/* ---------- repository ---------- */
+.quick-repo {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ margin-bottom: 12px;
+ padding: 12px 14px;
+ border: 1px dashed var(--accent);
+ border-radius: 8px;
+}
+.quick-repo p { margin: 0; }
+.quick-repo + .row { margin-top: 0; }
+
/* ---------- drawing ---------- */
.toolbar {
display: flex;
From 2e6099cd278c10c06f22226383aa0a6c37acaa31 Mon Sep 17 00:00:00 2001
From: Cedric <131578574+ced2711@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:22:01 -0500
Subject: [PATCH 5/7] Enable Sign in with GitHub with the Commit Canvas OAuth
app
---
web/src/app.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/web/src/app.js b/web/src/app.js
index 93034f9..cec33d2 100644
--- a/web/src/app.js
+++ b/web/src/app.js
@@ -2,7 +2,7 @@
const MAX_COMMITS = 2000;
const MANY_COMMITS = 200;
// OAuth App client ID for "Sign in with GitHub" (device flow, desktop app only).
- const OAUTH_CLIENT_ID = '';
+ const OAUTH_CLIENT_ID = 'Ov23liiuYaEx3OwBhJGK';
const $ = (id) => document.getElementById(id);
const desktop = window.__TAURI__ ?? null;
From ee48dd2d99fede2ac0a5ce002d1f34228a47b6bc Mon Sep 17 00:00:00 2001
From: Cedric <131578574+ced2711@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:24:59 -0500
Subject: [PATCH 6/7] Keep the page from scrolling when the graph is rebuilt
---
web/src/app.js | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/web/src/app.js b/web/src/app.js
index cec33d2..dd014d4 100644
--- a/web/src/app.js
+++ b/web/src/app.js
@@ -176,8 +176,11 @@
});
}
renderCells();
- // Start at the most recent weeks on narrow screens, like GitHub does.
+ // Start at the most recent weeks on narrow screens, like GitHub does,
+ // without letting the rebuild move the page itself.
+ const pageY = window.scrollY;
graph.parentElement.scrollLeft = graph.parentElement.scrollWidth;
+ window.scrollTo(window.scrollX, pageY);
}
function renderCells() {
@@ -1072,4 +1075,5 @@
// The desktop app keeps its own storage, so remember the token by default there.
$('remember').checked = saved.token ? saved.remember : Boolean(desktop);
if (saved.token) connect(saved.token, saved.remember);
+ requestAnimationFrame(() => window.scrollTo(0, 0));
})();
From f820c3416835c450a044a7754d3fcc76f4f6c0c2 Mon Sep 17 00:00:00 2001
From: Cedric <131578574+ced2711@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:30:57 -0500
Subject: [PATCH 7/7] Drop the web version; Commit Canvas is desktop-only
GitHub Pages is switched off, the Pages workflow is removed, the interface
folder is renamed from web/ to ui/, and the README now only describes the
desktop app and its Sign in with GitHub flow.
---
.github/workflows/pages.yml | 30 ------------------------------
README.md | 21 ++++++++++-----------
src-tauri/tauri.conf.json | 2 +-
{web => ui}/index.html | 0
{web => ui}/src/app.js | 0
{web => ui}/src/font.js | 0
{web => ui}/src/github.js | 0
{web => ui}/src/i18n.js | 0
{web => ui}/styles.css | 0
9 files changed, 11 insertions(+), 42 deletions(-)
delete mode 100644 .github/workflows/pages.yml
rename {web => ui}/index.html (100%)
rename {web => ui}/src/app.js (100%)
rename {web => ui}/src/font.js (100%)
rename {web => ui}/src/github.js (100%)
rename {web => ui}/src/i18n.js (100%)
rename {web => ui}/styles.css (100%)
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
deleted file mode 100644
index 3833ecf..0000000
--- a/.github/workflows/pages.yml
+++ /dev/null
@@ -1,30 +0,0 @@
-name: Deploy web app
-
-on:
- push:
- branches: [main]
- paths: ['web/**', '.github/workflows/pages.yml']
- workflow_dispatch:
-
-permissions:
- contents: read
- pages: write
- id-token: write
-
-concurrency:
- group: pages
- cancel-in-progress: true
-
-jobs:
- deploy:
- runs-on: ubuntu-latest
- environment:
- name: github-pages
- url: ${{ steps.deployment.outputs.page_url }}
- steps:
- - uses: actions/checkout@v4
- - uses: actions/upload-pages-artifact@v3
- with:
- path: web
- - id: deployment
- uses: actions/deploy-pages@v4
diff --git a/README.md b/README.md
index 3b43fe7..743a48c 100644
--- a/README.md
+++ b/README.md
@@ -30,14 +30,14 @@ Get the installer for your system from the [latest release](https://github.com/c
The builds are not code‑signed. On Windows, if SmartScreen appears choose **More info → Run anyway**. On macOS, if the app is reported as damaged, run `xattr -cr "/Applications/Commit Canvas.app"` once.
-Prefer not to install anything? The same app runs in the browser at .
+On Windows you can also run `commit-canvas.exe` directly without installing.
## Use it
The app walks you through four numbered steps:
-1. **Sign in.** In the desktop app, if you already use the GitHub CLI, click **Use my GitHub CLI login**. Otherwise click **Create a token** — GitHub opens with the right permission (`repo`) ticked — press **Generate token**, and paste the token.
-2. **Choose a repository**, or click **New repository** to create one just for drawing.
+1. **Sign in.** Click **Sign in with GitHub**: the app shows a short code (already copied) and opens GitHub in your browser — paste it and click **Authorize**. If you use the GitHub CLI, **Use my GitHub CLI login** is even quicker; pasting a personal access token also works.
+2. **Choose a repository.** Repositories meant for drawing are recognized and picked automatically; if you have none, one click creates `commit-canvas-art`.
3. **Draw.**
4. **Send to GitHub**, check the summary, and confirm.
@@ -53,15 +53,14 @@ For commits to count, GitHub requires the repository to be a non‑fork you own,
### Privacy
-The token is sent only to `api.github.com`. In the desktop app it is remembered on your computer by default; on the web page it only lives in the current tab unless you tick **Remember on this device**. **Sign out** removes it. You can revoke the token on GitHub at any time.
+Your GitHub login stays on your computer and is only sent to GitHub. **Sign out** removes it, and you can revoke the app's access on GitHub at any time (Settings → Applications).
## Development
-The interface is plain HTML/CSS/JavaScript in [`web/`](web); the desktop app wraps it with [Tauri](https://tauri.app) in [`src-tauri/`](src-tauri).
+Commit Canvas is a [Tauri](https://tauri.app) app: the interface is plain HTML/CSS/JavaScript in [`ui/`](ui) and the native shell is in [`src-tauri/`](src-tauri).
-- Web: serve `web/` with any static server, e.g. `python -m http.server -d web`.
-- Desktop: install Node.js and Rust, then `npm install`, `npm run icons`, and `npm run dev` (or `npm run build` for installers).
-- Releases: pushing a `v*` tag builds Windows, macOS and Linux installers with GitHub Actions and publishes them as a release. Pushes to `main` redeploy the web version to GitHub Pages.
+- Install Node.js and Rust, then `npm install`, `npm run icons`, and `npm run dev` (or `npm run build` for installers).
+- Releases: pushing a `v*` tag builds Windows, macOS and Linux installers with GitHub Actions and publishes them as a release. Pull requests build the same installers as downloadable artifacts.
## Please be fair
@@ -75,11 +74,11 @@ Decorative commits are not work. Use this for fun, in a repository you own, and
**功能**:完整显示你已有的绿点,新加的格子带圆点标记,并可「预览效果」;默认避开已有贡献的日子;四种深浅画笔和橡皮擦(每种显示对应 commit 数);文字和模板(爱心 / HELLO / 波浪 / 星星);撤销 / 重做、整体移动、清空、放大格子、键盘绘图、触屏滑动模式;最近一年或任意年份;在软件里选择或一键新建专用仓库;提交前核对账号、仓库、分支、数量和日期;可撤销上次提交;保存 / 打开设计文件(兼容旧版),导出 Bash / PowerShell 脚本;自动保存草稿;中英文界面。
-**下载**:到 [Releases](https://github.com/ced2711/commit-canvas/releases/latest) 下载对应系统的安装包——Windows 选 `…_x64-setup.exe`,macOS 选 `…_universal.dmg`,Linux 选 `.AppImage` 或 `.deb`。安装包没有代码签名:Windows 弹出 SmartScreen 时点「更多信息 → 仍要运行」;macOS 提示“已损坏”时运行一次 `xattr -cr "/Applications/Commit Canvas.app"`。不想安装也可以直接用网页版:。
+**下载**:到 [Releases](https://github.com/ced2711/commit-canvas/releases/latest) 下载对应系统的安装包——Windows 选 `…_x64-setup.exe`,macOS 选 `…_universal.dmg`,Linux 选 `.AppImage` 或 `.deb`。安装包没有代码签名:Windows 弹出 SmartScreen 时点「更多信息 → 仍要运行」;macOS 提示“已损坏”时运行一次 `xattr -cr "/Applications/Commit Canvas.app"`。Windows 也可以直接双击免安装的 `commit-canvas.exe`。
-**使用**:软件按 1–4 步引导:① 登录——桌面版装了 GitHub CLI 可一键沿用登录,否则点「创建 Token」(权限已自动勾好),点 Generate token 后粘贴回来;② 选择仓库或一键新建;③ 画画;④ 提交到 GitHub,核对后确认。通常几分钟内就能在主页看到,最长可能需要 24 小时。
+**使用**:软件按 1–4 步引导:① 登录——点「用 GitHub 账号登录」,软件显示验证码(已自动复制)并打开 GitHub,粘贴后点 Authorize 即可;装了 GitHub CLI 也可以一键沿用登录,或粘贴 Token;② 自动识别并选好画画用的仓库,没有的话一键创建 `commit-canvas-art`;③ 画画;④ 提交到 GitHub,核对后确认。通常几分钟内就能在主页看到,最长可能需要 24 小时。
-**说明**:每个格子会生成若干个中午时间的空 commit,作者是你的 GitHub `noreply` 邮箱,提交到默认分支并一次性快进推送;只有在你主动“撤销上次绘制”时才会强制重置分支。Token 只会发送给 `api.github.com`;桌面版默认记住在本机,网页版默认只保存在当前标签页。私有仓库需要在主页开启 “Private contributions” 才会显示。
+**说明**:每个格子会生成若干个中午时间的空 commit,作者是你的 GitHub `noreply` 邮箱,提交到默认分支并一次性快进推送;只有在你主动“撤销上次绘制”时才会强制重置分支。登录信息只保存在你的电脑上、只发送给 GitHub,点「退出」即可清除。私有仓库需要在主页开启 “Private contributions” 才会显示。
装饰性的 commit 不代表真实工作量,请只在自己的仓库里娱乐使用。
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 9cf21d7..9ed4bb9 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -4,7 +4,7 @@
"version": "1.0.0",
"identifier": "io.github.ced2711.commitcanvas",
"build": {
- "frontendDist": "../web"
+ "frontendDist": "../ui"
},
"app": {
"withGlobalTauri": true,
diff --git a/web/index.html b/ui/index.html
similarity index 100%
rename from web/index.html
rename to ui/index.html
diff --git a/web/src/app.js b/ui/src/app.js
similarity index 100%
rename from web/src/app.js
rename to ui/src/app.js
diff --git a/web/src/font.js b/ui/src/font.js
similarity index 100%
rename from web/src/font.js
rename to ui/src/font.js
diff --git a/web/src/github.js b/ui/src/github.js
similarity index 100%
rename from web/src/github.js
rename to ui/src/github.js
diff --git a/web/src/i18n.js b/ui/src/i18n.js
similarity index 100%
rename from web/src/i18n.js
rename to ui/src/i18n.js
diff --git a/web/styles.css b/ui/styles.css
similarity index 100%
rename from web/styles.css
rename to ui/styles.css