deploy 1779568775
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

This commit is contained in:
admin
2026-05-23 20:39:35 +00:00
commit 81dc2c211d
7 changed files with 1496 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
LICENSE

46
.woodpecker.yml Normal file
View File

@@ -0,0 +1,46 @@
steps:
build:
image: alpine
commands:
- apk add --no-cache rsync
- rm -rf site
- mkdir -p site
- rsync -a --exclude site --exclude .git ./ site/
deploy:
image: alpine/git
secrets:
- source: pages_token
target: pages_token
commands:
- echo "$pages_token" | wc -c
- git config --global user.name admin
- git config --global user.email admin@backend-3.com
- git clone https://admin:${pages_token}@git.backend-3.com/admin/font-design.git deploy
- cd deploy
- git checkout gh-pages || git checkout --orphan gh-pages
- find . -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +
- cp -r ../site/. .
- git add .
- git commit -m "deploy $(date +%s)" || true
- git push origin gh-pages --force
depends_on:
- build
when:
- event: push
- event: manual
- branch: main

18
LICENSE Normal file
View File

@@ -0,0 +1,18 @@
MIT License
Copyright (c) 2026 admin
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# font-design
font library / design ui

454
app.js Normal file
View File

@@ -0,0 +1,454 @@
const button = document.getElementById('cycleButton');
const fontName = document.getElementById('fontName');
const fontSize = document.getElementById('fontSize');
const fontWeight = document.getElementById('fontWeight');
const letterSpacing = document.getElementById('letterSpacing');
const fontSizeValue = document.getElementById('fontSizeValue');
const fontWeightValue = document.getElementById('fontWeightValue');
const letterSpacingValue = document.getElementById('letterSpacingValue');
const sizeGuide = document.getElementById('sizeGuide');
const weightGuide = document.getElementById('weightGuide');
const spacingGuide = document.getElementById('spacingGuide');
const debugFontSize = document.getElementById('debugFontSize');
const debugWeight = document.getElementById('debugWeight');
const debugLetterSpacing = document.getElementById('debugLetterSpacing');
const debugGuide = document.getElementById('debugGuide');
const debugFamily = document.getElementById('debugFamily');
const debugCategory = document.getElementById('debugCategory');
const editableFields = document.querySelectorAll('.editable');
const transformInputs = document.querySelectorAll('input[name="transform"]');
const loadedFonts = new Map();
let current = 0;
let activeEditable = document.querySelector('.editable');
const fonts = [
{ name: 'Satoshi', category: 'Sans', slug: 'satoshi' },
{ name: 'General Sans', category: 'Sans', slug: 'general-sans' },
{ name: 'Switzer', category: 'Sans', slug: 'switzer' },
{ name: 'Cabinet Grotesk', category: 'Sans', slug: 'cabinet-grotesk' },
{ name: 'Ranade', category: 'Sans', slug: 'ranade' },
{ name: 'Clash Display', category: 'Display', slug: 'clash-display' },
{ name: 'Stardom', category: 'Display', slug: 'stardom' },
{ name: 'Melodrama', category: 'Display', slug: 'melodrama' },
{ name: 'Panchang', category: 'Display', slug: 'panchang' },
{ name: 'Telma', category: 'Display', slug: 'telma' }
];
async function fetchAllFontshareFonts() {
try {
const response = await fetch('https://api.fontshare.com/v2/fonts');
if (!response.ok) {
return;
}
const data = await response.json();
if (!Array.isArray(data)) {
return;
}
data.forEach((font) => {
if (!font?.slug || !font?.family) {
return;
}
if (
fonts.some(
(entry) =>
entry.slug === font.slug
)
) {
return;
}
fonts.push({
name: font.family,
category:
font.styles?.[0]?.name || 'Sans',
slug: font.slug
});
});
} catch {
return;
}
}
function buildFontUrl(font) {
return `https://api.fontshare.com/v2/css?f[]=${font.slug}@300,400,500,600,700,800&display=swap`;
}
function injectFont(font) {
if (loadedFonts.has(font.slug)) {
return loadedFonts.get(font.slug);
}
const promise = new Promise((resolve, reject) => {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = buildFontUrl(font);
link.onload = resolve;
link.onerror = reject;
document.head.appendChild(link);
});
loadedFonts.set(font.slug, promise);
return promise;
}
function updateLiveValues() {
fontSizeValue.textContent = `${fontSize.value}px`;
fontWeightValue.textContent = fontWeight.value;
letterSpacingValue.textContent = `${letterSpacing.value}px`;
debugFontSize.textContent = `${fontSize.value}px`;
debugWeight.textContent = fontWeight.value;
debugLetterSpacing.textContent = `${letterSpacing.value}px`;
}
function setGuide(node, min, max, rangeMin, rangeMax) {
const left =
((min - rangeMin) / (rangeMax - rangeMin)) * 100;
const width =
((max - min) / (rangeMax - rangeMin)) * 100;
node.style.left = `${left}%`;
node.style.width = `${width}%`;
}
function updateSmartGuides() {
const text =
activeEditable?.textContent?.trim() || '';
const size = Number(fontSize.value);
const weight = Number(fontWeight.value);
const spacing = Number(letterSpacing.value);
const activeFont = fonts[current];
const [sizeMin, sizeMax] =
SmartGuides.getOptimalSize(text.length);
const [weightMin, weightMax] =
SmartGuides.getOptimalWeight(
activeFont.category,
size
);
const [spacingMin, spacingMax] =
SmartGuides.getOptimalSpacing(
size,
weight
);
setGuide(sizeGuide, sizeMin, sizeMax, 8, 320);
setGuide(weightGuide, weightMin, weightMax, 100, 900);
setGuide(spacingGuide, spacingMin, spacingMax, -12, 40);
debugGuide.textContent =
SmartGuides.getGuideLabel(
size,
weight,
spacing
);
}
function getTransformValue() {
return (
document.querySelector(
'input[name="transform"]:checked'
)?.value || 'none'
);
}
function applyStyles() {
const size = `${fontSize.value}px`;
const weight = fontWeight.value;
const spacing = `${letterSpacing.value}px`;
const transform = getTransformValue();
editableFields.forEach((field) => {
field.style.fontSize = size;
field.style.fontWeight = weight;
field.style.letterSpacing = spacing;
field.style.textTransform = transform;
});
updateLiveValues();
updateSmartGuides();
}
async function applyFont(index) {
const font = fonts[index];
if (!font) {
return;
}
try {
await injectFont(font);
document.documentElement.style.setProperty(
'--active-font',
`"${font.name}",system-ui,sans-serif`
);
fontName.textContent =
`${font.name} · ${font.category}`;
debugFamily.textContent = font.name;
debugCategory.textContent =
font.category;
} catch {
document.documentElement.style.setProperty(
'--active-font',
'system-ui,sans-serif'
);
fontName.textContent =
`Fallback · System`;
}
updateSmartGuides();
}
async function cycleFont() {
current =
(current + 1) % fonts.length;
await applyFont(current);
}
function bindEditable(field) {
field.addEventListener('focus', () => {
activeEditable = field;
updateSmartGuides();
});
field.addEventListener('input', () => {
updateSmartGuides();
});
}
function bindEvents() {
button.addEventListener('click', cycleFont);
fontSize.addEventListener('input', applyStyles);
fontWeight.addEventListener('input', applyStyles);
letterSpacing.addEventListener('input', applyStyles);
transformInputs.forEach((input) => {
input.addEventListener('change', applyStyles);
});
editableFields.forEach(bindEditable);
}
async function init() {
bindEvents();
updateLiveValues();
applyStyles();
await fetchAllFontshareFonts();
await applyFont(current);
}
// v26
const fontUpload =
document.getElementById(
'fontUpload'
);
const customFonts =
new Map();
function sanitizeFontName(
value
) {
return value
.replace(/\.[^/.]+$/, '')
.replace(/[^a-z0-9]/gi, '-');
}
async function loadLocalFont(
file
) {
if (!file) {
return;
}
const extension =
file.name
.split('.')
.pop()
?.toLowerCase();
const supported =
['ttf','otf','woff','woff2'];
if (
!supported.includes(
extension
)
) {
return;
}
const family =
sanitizeFontName(
file.name
);
const objectUrl =
URL.createObjectURL(
file
);
try {
const fontFace =
new FontFace(
family,
`url(${objectUrl})`
);
await fontFace.load();
document.fonts.add(
fontFace
);
customFonts.set(
family,
objectUrl
);
document.documentElement.style.setProperty(
'--active-font',
`"${family}",system-ui,sans-serif`
);
fontName.textContent =
`${family} · Local`;
debugFamily.textContent =
family;
debugCategory.textContent =
'Local';
requestAnimationFrame(
() => {
applyStyles();
updateSmartGuides();
}
);
} catch {
document.documentElement.style.setProperty(
'--active-font',
'system-ui,sans-serif'
);
fontName.textContent =
'Fallback · System';
}
}
fontUpload.addEventListener(
'change',
async (event) => {
const file =
event.target.files?.[0];
await loadLocalFont(
file
);
event.target.value =
'';
}
);
const controlsPane =
document.querySelector(
'.controls-pane'
);
const controlsToggle =
document.getElementById(
'controlsToggle'
);
let minimized = false;
function syncControlsState() {
controlsPane.classList.toggle(
'minimized',
minimized
);
controlsToggle.textContent =
minimized
? ''
: '✕';
}
controlsToggle.addEventListener(
'click',
() => {
minimized = !minimized;
syncControlsState();
}
);
syncControlsState();
init();

468
index.html Normal file
View File

@@ -0,0 +1,468 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta
name="viewport"
content="width=device-width,initial-scale=1,viewport-fit=cover"
/>
<title>
font prev
</title>
<link
rel="stylesheet"
href="./style.css"
/>
</head>
<body>
<main class="app">
<section class="preview-pane">
<div class="grid">
<div class="row">
<!-- -->
<div class="label">
Hero
</div>
<div
class="editable hero"
contenteditable="true"
spellcheck="false"
>
Satoshi
</div>
</div>
<div class="row">
<div class="label">
Display
</div>
<div
class="editable xl"
contenteditable="true"
spellcheck="false"
>
Modern display typography.
</div>
</div>
<div class="row">
<div class="label">
Headline
</div>
<div
class="editable lg"
contenteditable="true"
spellcheck="false"
>
Refined geometric details with clean spacing.
</div>
</div>
<div class="row">
<div class="label">
Body
</div>
<div
class="editable md"
contenteditable="true"
spellcheck="false"
>
Satoshi is a modernist sans serif.
</div>
</div>
<div class="row">
<div class="label">
Caption
</div>
<div
class="editable sm"
contenteditable="true"
spellcheck="false"
>
Edit text inline and cycle fonts.
</div>
</div>
</div>
</section>
<section class="controls-pane">
<div class="top">
<div
class="pill"
id="fontName"
>
Initializing...
</div>
<div class="top-actions">
<label class="font-upload">
<span>
Upload Font
</span>
<input
id="fontUpload"
type="file"
accept=".ttf,.otf,.woff,.woff2"
/>
</label>
<button id="cycleButton">
Change Font
</button>
</div>
<button
class="controls-toggle"
id="controlsToggle"
type="button"
aria-label="Toggle controls"
>
</button>
</div>
<div class="controls">
<div class="control">
<div class="control-head">
<label for="fontSize">
Size
</label>
<div
class="live-value"
id="fontSizeValue"
>
96px
</div>
</div>
<div class="slider-wrap">
<div
class="smart-guide"
id="sizeGuide"
></div>
<input
id="fontSize"
type="range"
min="8"
max="320"
step="1"
value="96"
/>
</div>
</div>
<div class="control">
<div class="control-head">
<label for="fontWeight">
Weight
</label>
<div
class="live-value"
id="fontWeightValue"
>
700
</div>
</div>
<div class="slider-wrap">
<div
class="smart-guide"
id="weightGuide"
></div>
<input
id="fontWeight"
type="range"
min="100"
max="900"
step="1"
value="700"
/>
</div>
</div>
<div class="control">
<div class="control-head">
<label for="letterSpacing">
Spacing
</label>
<div
class="live-value"
id="letterSpacingValue"
>
0px
</div>
</div>
<div class="slider-wrap">
<div
class="smart-guide"
id="spacingGuide"
></div>
<input
id="letterSpacing"
type="range"
min="-12"
max="40"
step="0.25"
value="0"
/>
</div>
</div>
<div class="control transform-control">
<div class="control-head">
<label>
Transform
</label>
</div>
<div class="radio-group">
<label class="radio">
<input
type="radio"
name="transform"
value="none"
checked
/>
<span>
None
</span>
</label>
<label class="radio">
<input
type="radio"
name="transform"
value="lowercase"
/>
<span>
Lowercase
</span>
</label>
<label class="radio">
<input
type="radio"
name="transform"
value="uppercase"
/>
<span>
Uppercase
</span>
</label>
<label class="radio">
<input
type="radio"
name="transform"
value="capitalize"
/>
<span>
Capitalize
</span>
</label>
</div>
</div>
</div>
</section>
</main>
<div
class="debug-banner"
id="debugBanner"
>
<div class="debug-track">
<span class="debug-item">
<span class="debug-key">
Family
</span>
<span
class="debug-value"
id="debugFamily"
>
Satoshi
</span>
</span>
<span class="debug-divider"></span>
<span class="debug-item">
<span class="debug-key">
Category
</span>
<span
class="debug-value"
id="debugCategory"
>
Sans
</span>
</span>
<span class="debug-divider"></span>
<span class="debug-item">
<span class="debug-key">
Font Size
</span>
<span
class="debug-value"
id="debugFontSize"
>
96px
</span>
</span>
<span class="debug-divider"></span>
<span class="debug-item">
<span class="debug-key">
Weight
</span>
<span
class="debug-value"
id="debugWeight"
>
700
</span>
</span>
<span class="debug-divider"></span>
<span class="debug-item">
<span class="debug-key">
Spacing
</span>
<span
class="debug-value"
id="debugLetterSpacing"
>
0px
</span>
</span>
<span class="debug-divider"></span>
<span class="debug-item">
<span class="debug-key">
Guide
</span>
<span
class="debug-value"
id="debugGuide"
>
Balanced
</span>
</span>
</div>
</div>
<script src="./smart-guides.js"></script>
<script src="./app.js"></script>
</body>
</html>

506
style.css Normal file
View File

@@ -0,0 +1,506 @@
:root {
--bg: #f5f5f7;
--surface: rgba(255,255,255,0.06);
--surface-hover: rgba(255,255,255,0.12);
--border: rgba(255,255,255,0.08);
--text: #111111;
--muted: #6d6d73;
--active-font: system-ui,sans-serif;
--radius: 20px;
--radius-sm: 14px;
--control-height: 46px;
--panel-width: 380px;
--panel-padding: 16px;
}
*,
*::before,
*::after {
box-sizing: border-box;
min-width: 0;
}
html {
width: 100%;
min-height: 100%;
overflow-x: hidden;
text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100dvh;
overflow-x: hidden;
overscroll-behavior-y: none;
background: var(--bg);
color: var(--text);
font:
500 14px/1.4
system-ui,
sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: geometricPrecision;
padding:
env(safe-area-inset-top)
env(safe-area-inset-right)
calc(76px + env(safe-area-inset-bottom))
env(safe-area-inset-left);
}
button,
input,
textarea,
select {
font: inherit;
}
button,
input[type="range"],
.radio,
.font-upload {
-webkit-tap-highlight-color: transparent;
}
.row,
.row * {
font-family: var(--active-font) !important;
}
.app {
width: 100%;
min-height: 100dvh;
}
.preview-pane {
width: 100%;
min-height: calc(100dvh - 100px);
display: flex;
align-items: center;
justify-content: center;
padding-right: calc(var(--panel-width) + 40px);
}
.controls-pane {
position: fixed;
top: 20px;
right: 20px;
width: min(
var(--panel-width),
calc(100vw - 32px)
);
max-height: calc(100dvh - 40px);
z-index: 9998;
overflow: hidden auto;
border-radius: 28px;
border: 1px solid rgba(255,255,255,0.18);
background:
linear-gradient(
180deg,
rgba(255,255,255,0.18),
rgba(255,255,255,0.08)
);
backdrop-filter: blur(40px) saturate(180%);
-webkit-backdrop-filter: blur(40px) saturate(180%);
box-shadow:
0 0px 0px rgba(0,0,0,0.16),
inset 0 0px 0 rgba(255,255,255,0.16);
scrollbar-width: none;
transition:
width 180ms ease,
max-height 180ms ease,
transform 180ms ease;
}
.controls-pane::-webkit-scrollbar {
display: none;
}
.controls-pane.minimized {
width: 210px;
max-height: 72px;
}
.controls-pane.minimized .controls {
opacity: 0;
pointer-events: none;
transform: translateY(-6px);
}
.controls-pane.minimized .top-actions {
display: none;
}
.top {
display: flex;
align-items: center;
gap: 10px;
padding: var(--panel-padding);
}
.top-actions {
flex: 1;
display: flex;
gap: 8px;
}
.controls {
display: grid;
gap: 12px;
padding:
0
var(--panel-padding)
var(--panel-padding);
transition:
opacity 160ms ease,
transform 160ms ease;
}
.control,
.row,
.radio,
button,
.font-upload,
.pill {
border: 1px solid var(--border);
background: var(--surface);
}
.control,
.row {
border-radius: var(--radius);
}
.control {
display: grid;
gap: 12px;
padding: 14px;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.row {
padding: 20px;
}
.grid {
width: 100%;
display: grid;
gap: 12px;
}
.top-actions button,
.top-actions .font-upload,
.controls-toggle {
height: var(--control-height);
}
button,
.font-upload,
.controls-toggle,
.radio {
appearance: none;
border-radius: var(--radius-sm);
transition:
transform 140ms ease,
background 140ms ease;
}
button,
.font-upload {
padding: 0 14px;
cursor: pointer;
font-size: 12px;
font-weight: 700;
}
button:hover,
.font-upload:hover,
.radio:hover,
.controls-toggle:hover {
background: var(--surface-hover);
}
button:active,
.font-upload:active,
.radio:active,
.controls-toggle:active {
transform: scale(0.985);
}
.font-upload {
display: inline-flex;
align-items: center;
justify-content: center;
user-select: none;
}
.font-upload input {
display: none;
}
.controls-toggle {
width: 46px;
min-width: 46px;
display: grid;
place-items: center;
cursor: pointer;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.pill {
min-height: 42px;
padding: 0 14px;
border-radius: 999px;
display: inline-flex;
align-items: center;
color: var(--muted);
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.control-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.control label,
.label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
}
.label {
margin-bottom: 10px;
}
.live-value {
font-size: 11px;
font-weight: 700;
}
.slider-wrap {
position: relative;
display: flex;
align-items: center;
}
.smart-guide {
position: absolute;
top: 50%;
left: 0;
height: 8px;
border-radius: 999px;
background: #111111;
opacity: 0.14;
transform: translateY(-50%);
pointer-events: none;
}
input[type="range"] {
width: 100%;
appearance: none;
height: 8px;
border-radius: 999px;
background: rgba(0,0,0,0.08);
}
input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
border-radius: 999px;
border: none;
background: #111111;
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
border: none;
border-radius: 999px;
background: #111111;
}
.radio-group {
display: grid;
gap: 8px;
}
.radio {
min-height: 42px;
display: flex;
align-items: center;
gap: 10px;
padding: 0 12px;
cursor: pointer;
}
.radio span {
flex: 1;
font-size: 12px;
font-weight: 600;
}
.radio input {
appearance: none;
width: 14px;
height: 14px;
border-radius: 999px;
border: 1px solid rgba(0,0,0,0.2);
}
.radio input:checked {
background: #111111;
border-color: #111111;
}
.editable {
width: 100%;
outline: none;
background: transparent;
overflow-wrap: anywhere;
caret-color: #111111;
}
.hero {
font-size: clamp(72px,12vw,180px);
line-height: 0.9;
letter-spacing: -0.08em;
font-weight: 700;
}
.xl {
font-size: clamp(36px,5vw,68px);
line-height: 1;
letter-spacing: -0.05em;
font-weight: 600;
}
.lg {
font-size: clamp(24px,3vw,42px);
line-height: 1.08;
letter-spacing: -0.035em;
font-weight: 600;
}
.md {
font-size: clamp(16px,2vw,22px);
line-height: 1.5;
letter-spacing: -0.015em;
font-weight: 500;
}
.sm {
font-size: clamp(13px,1.6vw,16px);
line-height: 1.7;
font-weight: 500;
color: var(--muted);
}
.debug-banner {
position: fixed;
inset:
auto
0
0;
z-index: 9999;
border-top: 1px solid rgba(0,0,0,0.06);
background: rgba(255,255,255,0.5);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
}
.debug-track {
min-height: 52px;
display: flex;
align-items: center;
gap: 14px;
overflow-x: auto;
scrollbar-width: none;
padding:
0
14px
env(safe-area-inset-bottom);
}
.debug-track::-webkit-scrollbar {
display: none;
}
.debug-item {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.debug-key,
.debug-value {
font:
600 10px/1
ui-monospace,
monospace;
}
.debug-key {
opacity: 0.45;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.debug-divider {
width: 1px;
height: 10px;
background: rgba(0,0,0,0.08);
}
@media (max-width: 1180px) {
.preview-pane {
padding-right: 0;
}
.controls-pane {
top: auto;
right: 14px;
bottom: 14px;
left: 14px;
width: auto;
max-height: 72dvh;
}
.controls-pane.minimized {
width: auto;
}
}
@media (max-width: 640px) {
.top {
flex-wrap: wrap;
}
.top-actions {
width: 100%;
display: grid;
grid-template-columns: repeat(2,minmax(0,1fr));
}
.row {
padding: 16px;
}
.hero {
font-size: clamp(52px,14vw,110px);
}
.xl {
font-size: clamp(28px,8vw,56px);
}
}